diff --git a/.idea/codeInsightSettings.xml b/.idea/codeInsightSettings.xml new file mode 100644 index 00000000..b328b017 --- /dev/null +++ b/.idea/codeInsightSettings.xml @@ -0,0 +1,8 @@ + + + + + com.google.protobuf + + + \ No newline at end of file diff --git a/.idea/templateLanguages.xml b/.idea/templateLanguages.xml new file mode 100644 index 00000000..1d53e312 --- /dev/null +++ b/.idea/templateLanguages.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 430cba15..c824a6c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM maven:3.8.6-eclipse-temurin-17 as builder +FROM maven:3.9.5-eclipse-temurin-21 as builder WORKDIR /usr/src/app COPY . . @@ -6,7 +6,7 @@ ARG MAVEN_OPTS RUN mvn package -DskipTests=true RUN java LibVipsDownloader.java -FROM eclipse-temurin:17-jdk +FROM eclipse-temurin:21-jdk SHELL ["bash", "-c"] RUN mkdir -p /opt/smithereen diff --git a/README.md b/README.md index 44989eb7..c4acd57d 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,12 @@ If you have any questions or feedback, there's a [Telegram chat](https://t.me/Sm ### Running directly on your server 1. Install and configure MySQL -2. Install maven and JDK >=17 if you don't have it already +2. Install maven and JDK >=21 if you don't have it already 3. Build the jar by running `mvn package -DskipTests=true` and place the one with dependencies at `/opt/smithereen/smithereen.jar` 4. Set up the image processing native library ([libvips](https://github.com/libvips/libvips)): run `java LibVipsDownloader.java` to automatically download a prebuilt one from [here](https://github.com/lovell/sharp-libvips). If you already have libvips installed on your system, you may skip this step, but be aware that not all libvips builds include all the features Smithereen needs. 5. Install and configure [imgproxy](https://docs.imgproxy.net/GETTING_STARTED) 6. Fill in the config file, see a commented example [here](examples/config.properties) + - You can use either the local file system (default) or an S3-compatible object storage service for user-uploaded media files. 7. Create a new MySQL database and initialize it with the [schema](schema.sql) using a command (`mysql -p smithereen < schema.sql`) or any GUI like phpMyAdmin 8. Configure and start the daemon: assuming your distribution uses systemd, copy [the service file](examples/smithereen.service) to /etc/systemd/system, then run `systemctl daemon-reload` and `service smithereen start` 9. Run `java -jar /opt/smithereen/smithereen.jar /etc/smithereen/config.properties init_admin` to create the first account @@ -25,6 +26,37 @@ If you have any questions or feedback, there's a [Telegram chat](https://t.me/Sm Copy [Docker-specific config example](examples/config_docker.properties) to the project root directory as `config.properties` and edit it to set your domain. Also edit `docker-compose.yml` to add your imgproxy secrets. You can then use `docker-compose` to run Smithereen, MySQL, and imgproxy. You still need to [configure your web server to reverse proxy the port 4567](examples/nginx.conf). Create the first account by running `docker-compose exec web bash -c ./smithereen-init-admin`. +### Using S3 object storage + +Smithereen supports S3-compatible object storage for user-uploaded media files (but not media file cache for files downloaded from other servers). + +To enable S3 storage, set `upload.backend=s3` in your `config.properties`. Configure other properties depending on your cloud provider: +- `upload.s3.region`: the region to use, `us-east-1` by default. Required for AWS, but some other cloud providers accept arbitrary values here. +- `upload.s3.endpoint`: the S3 endpoint, `s3..amazonaws.com` by default. Required if **not** using AWS. +- `upload.s3.key_id` and `upload.s3.secret_key`: your credentials for request authentication. +- `upload.s3.bucket`: the name of your bucket. +- `upload.s3.override_path_style`: if `upload.s3.endpoint` is set, set this to `true` if your cloud provider requires +putting the bucket name into the hostname instead of in the path for API requests, like `.`. +- `upload.s3.protocol`: `https` by default, can be set to `http`. + +The following properties control the public URLs for clients to read the files from your S3 bucket. They're currently only used for imgproxy, but will be given out to clients directly when support for non-image (e.g. video) attachments arrives in a future Smithereen version: +- `upload.s3.hostname`: defaults to `s3-.amazonaws.com`. Needs to be set if not using AWS and `upload.s3.alias_host` is not set. +- `upload.s3.alias_host`: can be used instead of `upload.s3.hostname` if you don't want your bucket name to be visible. Requires that you have a CDN or a reverse proxy in front of the storage provider. + - If this is set, the bucket name is **not** included in the generated URLs. The URLs will have the form of `:///`. + - If this is **not** set, the generated URLs will be of the form `:////`. + +You will need to configure your bucket to allow anonymous read access to objects, but not allow directory listing. [Refer to Mastodon documentation on how to do this on different cloud providers.](https://docs.joinmastodon.org/admin/optional/object-storage/#minio) + +You will also need to configure imgproxy to allow it to access your S3 storage: +``` +IMGPROXY_ALLOWED_SOURCES=local://,https:/// +``` +[Make sure to include a trailing slash in the URL.](https://docs.imgproxy.net/configuration/options#IMGPROXY_ALLOWED_SOURCES) + ## Contributing If you would like to help translate Smithereen into your language, please [do so on Crowdin](https://crowdin.com/project/smithereen). + +## Federating with Smithereen + +Smithereen supports various features not found in most other ActivityPub server software. [See the federation document](/FEDERATION.md) if you would like to implement these ActivityPub extensions in your project. diff --git a/examples/config.properties b/examples/config.properties index 3b79e11f..39aaf315 100644 --- a/examples/config.properties +++ b/examples/config.properties @@ -14,6 +14,9 @@ domain=YOUR_DOMAIN_HERE # Filesystem path where user-uploaded files (profile pictures, post media) are stored. upload.path=/opt/smithereen/media/uploads +# Or, if you want to use S3 object storage, see readme for details +#upload.backend=s3 +#upload.s3. ... # Media cache temporarily stores files from other servers media_cache.path=/opt/smithereen/media/media_cache diff --git a/examples/config_docker.properties b/examples/config_docker.properties index d16efcb0..d0ba0ad4 100644 --- a/examples/config_docker.properties +++ b/examples/config_docker.properties @@ -16,6 +16,9 @@ domain=YOUR_DOMAIN_HERE # Filesystem path where user-uploaded files (profile pictures, post media) are stored. upload.path=/opt/smithereen/media/uploads +# Or, if you want to use S3 object storage, see readme for details +#upload.backend=s3 +#upload.s3. ... # Media cache temporarily stores files from other servers media_cache.path=/opt/smithereen/media/media_cache diff --git a/examples/nginx.conf b/examples/nginx.conf index fac655be..90664dd5 100644 --- a/examples/nginx.conf +++ b/examples/nginx.conf @@ -5,6 +5,7 @@ server{ server_name YOUR_DOMAIN_HERE; client_max_body_size 40M; + client_body_buffer_size 1M; location /i/ { proxy_pass http://127.0.0.1:4560; proxy_cache sm_images; diff --git a/pom.xml b/pom.xml index 3933bc91..bdbcb81e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,8 +6,8 @@ UTF-8 UTF-8 - 17 - 17 + 21 + 21 true @@ -112,7 +112,7 @@ frontend-maven-plugin - 1.12.0 + 1.14.2 v16.13.1 src/main/web @@ -190,7 +190,7 @@ me.grishka.sparkjava spark-core - 2.9.4+patch.1 + 2.9.4+patch.2 io.pebbletemplates @@ -214,11 +214,6 @@ jsoup 1.15.3 - - com.squareup.okhttp3 - okhttp - 3.14.9 - org.junit.jupiter junit-jupiter diff --git a/schema.sql b/schema.sql index a79789ee..3a42ae44 100644 --- a/schema.sql +++ b/schema.sql @@ -16,16 +16,54 @@ CREATE TABLE `accounts` ( `password` binary(32) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `invited_by` int unsigned DEFAULT NULL, - `access_level` tinyint unsigned NOT NULL DEFAULT '1', `preferences` text, `last_active` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `ban_info` text, `activation_info` json DEFAULT NULL, + `role` int unsigned DEFAULT NULL, + `promoted_by` int unsigned DEFAULT NULL, + `email_domain` varchar(150) NOT NULL DEFAULT '', + `last_ip` binary(16) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `email` (`email`), KEY `user_id` (`user_id`), KEY `invited_by` (`invited_by`), - CONSTRAINT `accounts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE + KEY `role` (`role`), + KEY `promoted_by` (`promoted_by`), + KEY `email_domain` (`email_domain`), + KEY `last_ip` (`last_ip`), + CONSTRAINT `accounts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `accounts_ibfk_2` FOREIGN KEY (`role`) REFERENCES `user_roles` (`id`) ON DELETE SET NULL, + CONSTRAINT `accounts_ibfk_3` FOREIGN KEY (`promoted_by`) REFERENCES `accounts` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `audit_log` +-- + +CREATE TABLE `audit_log` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `admin_id` int unsigned NOT NULL, + `action` int unsigned NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `owner_id` int DEFAULT NULL, + `object_id` bigint DEFAULT NULL, + `object_type` int unsigned DEFAULT NULL, + `extra` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `owner_id` (`owner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `blocks_email_domain` +-- + +CREATE TABLE `blocks_email_domain` ( + `domain` varchar(100) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `action` tinyint unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `note` text NOT NULL, + `creator_id` int unsigned NOT NULL, + PRIMARY KEY (`domain`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- @@ -53,6 +91,22 @@ CREATE TABLE `blocks_group_user` ( CONSTRAINT `blocks_group_user_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- +-- Table structure for table `blocks_ip` +-- + +CREATE TABLE `blocks_ip` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `address` binary(16) NOT NULL, + `prefix_length` tinyint unsigned NOT NULL, + `action` tinyint unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires_at` timestamp NOT NULL, + `note` text NOT NULL, + `creator_id` int unsigned NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- -- Table structure for table `blocks_user_domain` -- @@ -329,6 +383,42 @@ CREATE TABLE `media_cache` ( KEY `last_access` (`last_access`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- +-- Table structure for table `media_file_refs` +-- + +CREATE TABLE `media_file_refs` ( + `file_id` bigint unsigned NOT NULL, + `object_id` bigint NOT NULL, + `object_type` tinyint unsigned NOT NULL, + `owner_user_id` int unsigned DEFAULT NULL, + `owner_group_id` int unsigned DEFAULT NULL, + PRIMARY KEY (`object_id`,`object_type`,`file_id`), + KEY `file_id` (`file_id`), + KEY `owner_user_id` (`owner_user_id`), + KEY `owner_group_id` (`owner_group_id`), + CONSTRAINT `media_file_refs_ibfk_1` FOREIGN KEY (`file_id`) REFERENCES `media_files` (`id`), + CONSTRAINT `media_file_refs_ibfk_2` FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `media_file_refs_ibfk_3` FOREIGN KEY (`owner_group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `media_files` +-- + +CREATE TABLE `media_files` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `random_id` binary(18) NOT NULL, + `size` bigint unsigned NOT NULL, + `type` tinyint unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `metadata` json NOT NULL, + `ref_count` int unsigned NOT NULL DEFAULT '0', + `original_owner_id` int NOT NULL, + PRIMARY KEY (`id`), + KEY `ref_count` (`ref_count`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- -- Table structure for table `newsfeed` -- @@ -447,6 +537,23 @@ CREATE TABLE `qsearch_index` ( CONSTRAINT `qsearch_index_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=ascii; +-- +-- Table structure for table `report_actions` +-- + +CREATE TABLE `report_actions` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `report_id` int unsigned NOT NULL, + `user_id` int unsigned NOT NULL, + `action_type` tinyint unsigned NOT NULL, + `text` text, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `extra` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `report_id` (`report_id`), + CONSTRAINT `report_actions_ibfk_1` FOREIGN KEY (`report_id`) REFERENCES `reports` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- -- Table structure for table `reports` -- @@ -455,17 +562,18 @@ CREATE TABLE `reports` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `reporter_id` int unsigned DEFAULT NULL, `target_type` tinyint unsigned NOT NULL, - `content_type` tinyint unsigned DEFAULT NULL, - `target_id` int unsigned NOT NULL, - `content_id` bigint unsigned DEFAULT NULL, + `target_id` int NOT NULL, `comment` text NOT NULL, `moderator_id` int unsigned DEFAULT NULL, `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `action_time` timestamp NULL DEFAULT NULL, `server_domain` varchar(100) DEFAULT NULL, + `content` json DEFAULT NULL, + `state` tinyint unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `reporter_id` (`reporter_id`), - KEY `moderator_id` (`moderator_id`) + KEY `moderator_id` (`moderator_id`), + KEY `state` (`state`), + KEY `target_id` (`target_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- @@ -498,7 +606,8 @@ CREATE TABLE `sessions` ( `id` binary(64) NOT NULL, `account_id` int unsigned NOT NULL, `last_active` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `last_ip` varbinary(16) DEFAULT NULL, + `ip` binary(16) NOT NULL, + `user_agent` bigint NOT NULL, PRIMARY KEY (`id`), KEY `account_id` (`account_id`), CONSTRAINT `sessions_ibfk_1` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE @@ -550,6 +659,42 @@ CREATE TABLE `stats_daily` ( PRIMARY KEY (`day`,`type`,`object_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- +-- Table structure for table `user_agents` +-- + +CREATE TABLE `user_agents` ( + `hash` bigint NOT NULL, + `user_agent` text NOT NULL, + PRIMARY KEY (`hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `user_roles` +-- + +CREATE TABLE `user_roles` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `permissions` varbinary(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `user_staff_notes` +-- + +CREATE TABLE `user_staff_notes` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `target_id` int unsigned NOT NULL, + `author_id` int unsigned NOT NULL, + `text` text NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `target_id` (`target_id`), + CONSTRAINT `user_staff_notes_ibfk_1` FOREIGN KEY (`target_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- -- Table structure for table `users` -- @@ -578,9 +723,12 @@ CREATE TABLE `users` ( `flags` bigint unsigned NOT NULL, `endpoints` json DEFAULT NULL, `privacy` json DEFAULT NULL, + `ban_status` tinyint unsigned NOT NULL DEFAULT '0', + `ban_info` json DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`,`domain`), - UNIQUE KEY `ap_id` (`ap_id`) + UNIQUE KEY `ap_id` (`ap_id`), + KEY `ban_status` (`ban_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- @@ -624,4 +772,4 @@ CREATE TABLE `wall_posts` ( /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; --- Dump completed on 2023-10-26 17:12:16 +-- Dump completed on 2024-03-23 8:50:17 diff --git a/src/main/java/smithereen/CLI.java b/src/main/java/smithereen/CLI.java index 53d5fcb0..f5e3a0b3 100644 --- a/src/main/java/smithereen/CLI.java +++ b/src/main/java/smithereen/CLI.java @@ -82,8 +82,8 @@ public static void initializeAdmin(){ } SessionStorage.registerNewAccount(username, password, email, username, "", User.Gender.UNKNOWN); Account acc=SessionStorage.getAccountForUsernameAndPassword(username, password); - UserStorage.setAccountAccessLevel(acc.id, Account.AccessLevel.ADMIN); - System.out.println(E_BOLD+"You're all set! Now, make sure your web server is properly configured, then navigate to this server in your web browser and log into your account."+E_RESET); + UserStorage.setAccountRole(acc, 1, 0); // owner + System.out.println(E_BOLD+"You're all set! Now, make sure your web server is properly configured, then navigate to https://"+Config.domain+" in your web browser and log into your account."+E_RESET); }catch(SQLException|IOError x){ x.printStackTrace(); } diff --git a/src/main/java/smithereen/Config.java b/src/main/java/smithereen/Config.java index 8fa48c09..71987a09 100644 --- a/src/main/java/smithereen/Config.java +++ b/src/main/java/smithereen/Config.java @@ -31,8 +31,12 @@ import java.util.HashMap; import java.util.Map; import java.util.Properties; +import java.util.function.Function; +import java.util.stream.Collectors; import smithereen.model.ObfuscatedObjectIDType; +import smithereen.model.UserPermissions; +import smithereen.model.UserRole; import smithereen.storage.sql.SQLQueryBuilder; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; @@ -66,6 +70,9 @@ public class Config{ private static URI localURI; + public static StorageBackend storageBackend; + public static S3Configuration s3Configuration; + // following fields are kept in the config table in database and some are configurable from /settings/admin public static int dbSchemaVersion; @@ -90,13 +97,15 @@ public class Config{ public static byte[] objectIdObfuscationKey; public static int[][] objectIdObfuscationKeysByType=new int[ObfuscatedObjectIDType.values().length][]; + public static Map userRoles=new HashMap<>(); + private static final Logger LOG=LoggerFactory.getLogger(Config.class); public static void load(String filePath) throws IOException{ - FileInputStream in=new FileInputStream(filePath); Properties props=new Properties(); - props.load(in); - in.close(); + try(FileInputStream in=new FileInputStream(filePath)){ + props.load(in); + } dbHost=props.getProperty("db.host"); dbUser=props.getProperty("db.user"); @@ -124,6 +133,30 @@ public static void load(String filePath) throws IOException{ imgproxySalt=Utils.hexStringToByteArray(props.getProperty("imgproxy.salt")); if(imgproxyUrl.charAt(0)!='/') imgproxyUrl='/'+imgproxyUrl; + + storageBackend=switch(props.getProperty("upload.backend", "local")){ + case "local" -> StorageBackend.LOCAL; + case "s3" -> StorageBackend.S3; + default -> throw new IllegalStateException("Unexpected value for `upload.backend`: " + props.getProperty("upload.backend")); + }; + + if(storageBackend==StorageBackend.S3){ + s3Configuration=new S3Configuration( + requireProperty(props, "upload.s3.key_id"), + requireProperty(props, "upload.s3.secret_key"), + props.getProperty("upload.s3.endpoint"), + props.getProperty("upload.s3.region", "us-east-1"), + requireProperty(props, "upload.s3.bucket"), + switch(props.getProperty("upload.s3.protocol", "https")){ + case "http" -> "http"; + case "https" -> "https"; + default -> throw new IllegalArgumentException("`upload.s3.protocol` must be either \"https\" or \"http\""); + }, + props.getProperty("upload.s3.hostname"), + props.getProperty("upload.s3.alias_host"), + Boolean.parseBoolean(props.getProperty("upload.s3.override_path_style", "false")) + ); + } } public static void loadFromDatabase() throws SQLException{ @@ -245,10 +278,33 @@ public static String getServerDisplayName(){ return StringUtils.isNotEmpty(serverDisplayName) ? serverDisplayName : domain; } + public static void reloadRoles() throws SQLException{ + userRoles.clear(); + userRoles=new SQLQueryBuilder() + .selectFrom("user_roles") + .allColumns() + .executeAsStream(UserRole::fromResultSet) + .collect(Collectors.toMap(UserRole::id, Function.identity())); + } + + private static String requireProperty(Properties props, String name){ + String value=props.getProperty(name); + if(value==null) + throw new IllegalArgumentException("Config property `"+name+"` is required"); + return value; + } + public enum SignupMode{ OPEN, CLOSED, INVITE_ONLY, MANUAL_APPROVAL } + + public enum StorageBackend{ + LOCAL, + S3 + } + + public record S3Configuration(String keyID, String secretKey, String endpoint, String region, String bucket, String protocol, String hostname, String aliasHost, boolean overridePathStyle){} } diff --git a/src/main/java/smithereen/Mailer.java b/src/main/java/smithereen/Mailer.java index 65fde14a..f5305105 100644 --- a/src/main/java/smithereen/Mailer.java +++ b/src/main/java/smithereen/Mailer.java @@ -8,9 +8,12 @@ import java.io.IOException; import java.io.StringWriter; +import java.time.temporal.ChronoUnit; +import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Properties; import javax.mail.Authenticator; @@ -25,8 +28,10 @@ import javax.mail.internet.MimeMultipart; import smithereen.model.Account; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.lang.Lang; +import smithereen.model.UserBanInfo; +import smithereen.model.UserBanStatus; import smithereen.templates.Templates; import smithereen.util.BackgroundTaskRunner; import spark.Request; @@ -62,6 +67,7 @@ public void updateSession(){ if(StringUtils.isEmpty(Config.smtpPassword)){ session=Session.getInstance(props); }else{ + props.put("mail.smtp.auth", "true"); session=Session.getInstance(props, new Authenticator(){ @Override protected PasswordAuthentication getPasswordAuthentication(){ @@ -165,12 +171,50 @@ public void sendSignupInvitation(Request req, Account self, String email, String ), l.getLocale()); } + public void sendAccountBanNotification(Account self, UserBanStatus banStatus, UserBanInfo banInfo){ + Lang l=Lang.get(self.prefs.locale); + String text=switch(banStatus){ + case FROZEN -> l.get("email_account_frozen_body", Map.of( + "name", self.user.firstName, + "serverName", Config.serverDisplayName, + "date", l.formatDate(Objects.requireNonNull(banInfo.expiresAt()), self.prefs.timeZone, false) + )); + case SUSPENDED -> l.get("email_account_suspended_body", Map.of( + "name", self.user.firstName, + "serverName", Config.serverDisplayName, + "deletionDate", l.formatDate(Objects.requireNonNull(banInfo.bannedAt()).plus(30, ChronoUnit.DAYS), self.prefs.timeZone, false) + )); + default -> throw new IllegalArgumentException("Unexpected value: " + banStatus); + }; + String subject=switch(banStatus){ + case FROZEN -> l.get("email_account_frozen_subject", Map.of("domain", Config.domain)); + case SUSPENDED -> l.get("email_account_suspended_subject", Map.of("domain", Config.domain)); + default -> throw new IllegalArgumentException("Unexpected value: " + banStatus); + }; + String htmlText=text; + text=Utils.stripHTML(text); + if(StringUtils.isNotEmpty(banInfo.message())){ + htmlText+="

"+l.get("message_from_staff")+": "+Utils.stripHTML(banInfo.message()); + text+="\n\n"+l.get("message_from_staff")+": "+banInfo.message(); + } + send(self.email, subject, text, "generic", Map.of("text", htmlText), self.prefs.locale); + } + + public void sendActionConfirmationCode(Request req, Account self, String action, String code){ + LOG.trace("Sending code {} for action {}", code, action); + Lang l=Utils.lang(req); + String plaintext=Utils.stripHTML(l.get("email_confirmation_code", Map.of("name", self.user.firstName, "action", action)))+"\n\n"+code+"\n\n"+Utils.stripHTML(l.get("email_confirmation_code_info")); + String subject=l.get("email_confirmation_code_subject", Map.of("domain", Config.domain)); + send(self.email, subject, plaintext, "confirmation_code", Map.of("name", self.user.firstName, "action", action, "code", code), l.getLocale()); + } + private void send(String to, String subject, String plaintext, String templateName, Map templateParams, Locale templateLocale){ try{ MimeMessage msg=new MimeMessage(session); msg.setFrom(new InternetAddress(Config.mailFrom, Config.getServerDisplayName())); msg.setRecipients(Message.RecipientType.TO, to); msg.setSubject(subject); + msg.setSentDate(new Date()); MimeBodyPart plainPart=new MimeBodyPart(); plainPart.setContent(plaintext, "text/plain; charset=UTF-8"); diff --git a/src/main/java/smithereen/MicroFormatAwareHTMLWhitelist.java b/src/main/java/smithereen/MicroFormatAwareHTMLWhitelist.java index 1cced271..b475290a 100644 --- a/src/main/java/smithereen/MicroFormatAwareHTMLWhitelist.java +++ b/src/main/java/smithereen/MicroFormatAwareHTMLWhitelist.java @@ -10,7 +10,7 @@ import java.util.List; import java.util.regex.Pattern; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.util.Whitelist; @SuppressWarnings("deprecation") diff --git a/src/main/java/smithereen/SmithereenApplication.java b/src/main/java/smithereen/SmithereenApplication.java index 0301c751..b717071f 100644 --- a/src/main/java/smithereen/SmithereenApplication.java +++ b/src/main/java/smithereen/SmithereenApplication.java @@ -7,32 +7,45 @@ import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.io.StringWriter; +import java.net.InetAddress; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; import smithereen.activitypub.ActivityPub; import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.Actor; import smithereen.controllers.MailController; +import smithereen.controllers.UsersController; +import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.FloodControlViolationException; +import smithereen.exceptions.InaccessibleProfileException; +import smithereen.exceptions.ObjectNotFoundException; +import smithereen.exceptions.UserActionNotAllowedException; +import smithereen.exceptions.UserContentUnavailableException; +import smithereen.exceptions.UserErrorException; +import smithereen.lang.Lang; import smithereen.model.Account; import smithereen.model.ForeignGroup; import smithereen.model.ForeignUser; import smithereen.model.Group; import smithereen.model.SessionInfo; import smithereen.model.User; +import smithereen.model.UserBanStatus; +import smithereen.model.UserRole; import smithereen.model.WebDeltaResponse; -import smithereen.exceptions.BadRequestException; -import smithereen.exceptions.FloodControlViolationException; -import smithereen.exceptions.ObjectNotFoundException; -import smithereen.exceptions.UserActionNotAllowedException; -import smithereen.exceptions.UserContentUnavailableException; -import smithereen.exceptions.UserErrorException; import smithereen.routes.ActivityPubRoutes; import smithereen.routes.ApiRoutes; import smithereen.routes.FriendsRoutes; @@ -43,14 +56,15 @@ import smithereen.routes.ProfileRoutes; import smithereen.routes.SessionRoutes; import smithereen.routes.SettingsAdminRoutes; +import smithereen.routes.SettingsRoutes; import smithereen.routes.SystemRoutes; import smithereen.routes.WellKnownRoutes; import smithereen.sparkext.ActivityPubCollectionPageResponse; import smithereen.sparkext.ExtendedStreamingSerializer; import smithereen.storage.DatabaseSchemaUpdater; import smithereen.storage.GroupStorage; +import smithereen.storage.MediaStorageUtils; import smithereen.storage.SessionStorage; -import smithereen.routes.SettingsRoutes; import smithereen.storage.UserStorage; import smithereen.storage.sql.DatabaseConnectionManager; import smithereen.templates.RenderedTemplateResponse; @@ -62,16 +76,18 @@ import spark.Filter; import spark.Request; import spark.Response; -import spark.Spark; +import spark.Session; import spark.utils.StringUtils; -import static smithereen.Utils.randomAlphanumericString; -import static spark.Spark.*; +import static smithereen.Utils.*; import static smithereen.sparkext.SparkExtension.*; +import static spark.Spark.*; public class SmithereenApplication{ private static final Logger LOG; private static final ApplicationContext context; + private static HashMap accountIdsBySession=new HashMap<>(); + private static HashMap> sessionsByAccount=new HashMap<>(); static{ System.setProperty("org.slf4j.simpleLogger.logFile", "System.out"); @@ -102,6 +118,7 @@ public static void main(String[] args){ Config.load(args[0]); Config.loadFromDatabase(); DatabaseSchemaUpdater.maybeUpdate(); + Config.reloadRoles(); }catch(IOException|SQLException x){ throw new RuntimeException(x); } @@ -120,6 +137,7 @@ public static void main(String[] args){ ipAddress(Config.serverIP); port(Config.serverPort); + useVirtualThreadPool(); if(Config.staticFilesPath!=null) externalStaticFileLocation(Config.staticFilesPath); else @@ -131,24 +149,52 @@ public static void main(String[] args){ if(request.pathInfo().startsWith("/api/")) return; request.attribute("start_time", System.currentTimeMillis()); - if(request.session(false)==null || request.session().attribute("info")==null){ + Session session=request.session(false); + if(session==null || session.attribute("info")==null){ String psid=request.cookie("psid"); if(psid!=null){ if(!SessionStorage.fillSession(psid, request.session(true), request)){ response.removeCookie("/", "psid"); }else{ response.cookie("/", "psid", psid, 10*365*24*60*60, false); + SessionInfo info=sessionInfo(request); + if(info.account!=null){ + synchronized(SmithereenApplication.class){ + accountIdsBySession.put(request.session().id(), info.account.id); + sessionsByAccount.computeIfAbsent(info.account.id, HashSet::new).add(request.session().raw()); + } + } } } } - SessionInfo info=Utils.sessionInfo(request); + SessionInfo info=sessionInfo(request); if(info!=null && info.account!=null){ info.account=UserStorage.getAccount(info.account.id); - info.permissions=SessionStorage.getUserPermissions(info.account); - - if(System.currentTimeMillis()-info.account.lastActive.toEpochMilli()>=10*60*1000){ - info.account.lastActive=Instant.now(); - SessionStorage.setLastActive(info.account.id, request.cookie("psid"), info.account.lastActive); + if(info.account==null){ + response.removeCookie("/", "psid"); + request.session().invalidate(); + }else{ + info.permissions=SessionStorage.getUserPermissions(info.account); + + String ua=Objects.requireNonNull(request.userAgent(), ""); + long uaHash=hashUserAgent(ua); + InetAddress ip=getRequestIP(request); + if(System.currentTimeMillis()-info.account.lastActive.toEpochMilli()>=10*60*1000 || !Objects.equals(info.account.lastIP, ip) || !Objects.equals(info.ip, ip) || info.userAgentHash!=uaHash){ + info.account.lastActive=Instant.now(); + info.userAgentHash=uaHash; + info.ip=ip; + BackgroundTaskRunner.getInstance().submit(()->{ + try{ + SessionStorage.setLastActive(info.account.id, request.cookie("psid"), info.account.lastActive, ip, ua, uaHash); + }catch(SQLException x){ + LOG.warn("Error updating account session", x); + } + }); + } + } + }else{ + if(session!=null && session.attribute("bannedBot")!=null){ + throw new UserActionNotAllowedException(); } } // String hs=""; @@ -159,10 +205,11 @@ public static void main(String[] args){ request.attribute("popup", Boolean.TRUE); } String ua=request.userAgent(); - if(StringUtils.isNotEmpty(ua) && Utils.isMobileUserAgent(ua)){ + if(StringUtils.isNotEmpty(ua) && isMobileUserAgent(ua)){ request.attribute("mobile", Boolean.TRUE); } }); + before(SmithereenApplication::enforceAccountLimitationsIfAny); get("/", SmithereenApplication::indexPage); @@ -186,6 +233,11 @@ public static void main(String[] args){ postWithCSRF("/changeEmail", SessionRoutes::changeEmail); getLoggedIn("/activate", SessionRoutes::activateAccount); post("/requestInvite", SessionRoutes::requestSignupInvite); + getLoggedIn("/unfreezeBox", SessionRoutes::unfreezeBox); + postWithCSRF("/unfreeze", SessionRoutes::unfreeze); + postWithCSRF("/unfreezeChangePassword", SessionRoutes::unfreezeChangePassword); + getLoggedIn("/reactivateBox", SessionRoutes::reactivateBox); + postWithCSRF("/reactivate", SessionRoutes::reactivate); }); path("/settings", ()->{ @@ -223,32 +275,87 @@ public static void main(String[] args){ getLoggedIn("/privacy", SettingsRoutes::privacySettings); postWithCSRF("/privacy", SettingsRoutes::savePrivacySettings); getLoggedIn("/privacy/mobileEditSetting", SettingsRoutes::mobileEditPrivacy); + getLoggedIn("/deactivateAccountForm", SettingsRoutes::deactivateAccountForm); + postWithCSRF("/deactivateAccount", SettingsRoutes::deactivateAccount); path("/admin", ()->{ - getRequiringAccessLevel("", Account.AccessLevel.ADMIN, SettingsAdminRoutes::index); - postRequiringAccessLevelWithCSRF("/updateServerInfo", Account.AccessLevel.ADMIN, SettingsAdminRoutes::updateServerInfo); - getRequiringAccessLevel("/users", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::users); - getRequiringAccessLevel("/users/accessLevelForm", Account.AccessLevel.ADMIN, SettingsAdminRoutes::accessLevelForm); - postRequiringAccessLevelWithCSRF("/users/setAccessLevel", Account.AccessLevel.ADMIN, SettingsAdminRoutes::setUserAccessLevel); - getRequiringAccessLevel("/other", Account.AccessLevel.ADMIN, SettingsAdminRoutes::otherSettings); - postRequiringAccessLevelWithCSRF("/updateEmailSettings", Account.AccessLevel.ADMIN, SettingsAdminRoutes::saveEmailSettings); - postRequiringAccessLevelWithCSRF("/sendTestEmail", Account.AccessLevel.ADMIN, SettingsAdminRoutes::sendTestEmail); - getRequiringAccessLevel("/users/banForm", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::banUserForm); - getRequiringAccessLevel("/users/confirmUnban", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::confirmUnbanUser); - getRequiringAccessLevel("/users/confirmActivate", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::confirmActivateAccount); - postRequiringAccessLevelWithCSRF("/users/ban", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::banUser); - postRequiringAccessLevelWithCSRF("/users/unban", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::unbanUser); - postRequiringAccessLevelWithCSRF("/users/activate", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::activateAccount); - getRequiringAccessLevel("/signupRequests", Account.AccessLevel.ADMIN, SettingsAdminRoutes::signupRequests); - postRequiringAccessLevelWithCSRF("/signupRequests/:id/respond", Account.AccessLevel.ADMIN, SettingsAdminRoutes::respondToSignupRequest); - getRequiringAccessLevel("/reports", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::reportsList); - postRequiringAccessLevelWithCSRF("/reports/:id", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::reportAction); - postRequiringAccessLevelWithCSRF("/reports/:id/doAddCW", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::reportAddCW); - getRequiringAccessLevel("/federation", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::federationServerList); - getRequiringAccessLevel("/federation/:domain", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::federationServerDetails); - getRequiringAccessLevel("/federation/:domain/restrictionForm", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::federationServerRestrictionForm); - postRequiringAccessLevelWithCSRF("/federation/:domain/restrict", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::federationRestrictServer); - getRequiringAccessLevelWithCSRF("/federation/:domain/resetAvailability", Account.AccessLevel.MODERATOR, SettingsAdminRoutes::federationResetServerAvailability); + getRequiringPermission("", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::index); + postRequiringPermissionWithCSRF("/updateServerInfo", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::updateServerInfo); + path("/users", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::users); + getRequiringPermission("/roleForm", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::roleForm); + postRequiringPermissionWithCSRF("/setRole", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::setUserRole); + getRequiringPermission("/banForm", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::banUserForm); + getRequiringPermission("/confirmActivate", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::confirmActivateAccount); + postRequiringPermissionWithCSRF("/activate", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::activateAccount); + getRequiringPermission("/changeEmailForm", UserRole.Permission.MANAGE_USER_ACCESS, SettingsAdminRoutes::changeUserEmailForm); + postRequiringPermissionWithCSRF("/changeEmail", UserRole.Permission.MANAGE_USER_ACCESS, SettingsAdminRoutes::changeUserEmail); + getRequiringPermissionWithCSRF("/endSession", UserRole.Permission.MANAGE_USER_ACCESS, SettingsAdminRoutes::endUserSession); + }); + path("/reports", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportsList); + path("/:id", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::viewReport); + getRequiringPermissionWithCSRF("/markResolved", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportMarkResolved); + getRequiringPermissionWithCSRF("/markUnresolved", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportMarkUnresolved); + postRequiringPermissionWithCSRF("/addComment", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportAddComment); + getRequiringPermission("/content/:index", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportShowContent); + getRequiringPermission("/deleteContentForm", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportConfirmDeleteContent); + postRequiringPermissionWithCSRF("/deleteContent", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportDeleteContent); + }); + }); + path("/federation", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_FEDERATION, SettingsAdminRoutes::federationServerList); + getRequiringPermission("/:domain", UserRole.Permission.MANAGE_FEDERATION, SettingsAdminRoutes::federationServerDetails); + getRequiringPermission("/:domain/restrictionForm", UserRole.Permission.MANAGE_FEDERATION, SettingsAdminRoutes::federationServerRestrictionForm); + postRequiringPermissionWithCSRF("/:domain/restrict", UserRole.Permission.MANAGE_FEDERATION, SettingsAdminRoutes::federationRestrictServer); + getRequiringPermissionWithCSRF("/:domain/resetAvailability", UserRole.Permission.MANAGE_FEDERATION, SettingsAdminRoutes::federationResetServerAvailability); + }); + path("/roles", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_ROLES, SettingsAdminRoutes::roles); + getRequiringPermission("/create", UserRole.Permission.MANAGE_ROLES, SettingsAdminRoutes::createRoleForm); + postRequiringPermissionWithCSRF("/create", UserRole.Permission.MANAGE_ROLES, SettingsAdminRoutes::saveRole); + getRequiringPermission("/:id", UserRole.Permission.MANAGE_ROLES, SettingsAdminRoutes::editRole); + postRequiringPermissionWithCSRF("/:id", UserRole.Permission.MANAGE_ROLES, SettingsAdminRoutes::saveRole); + postRequiringPermissionWithCSRF("/:id/delete", UserRole.Permission.MANAGE_ROLES, SettingsAdminRoutes::deleteRole); + }); + path("/signupRequests", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_INVITES, SettingsAdminRoutes::signupRequests); + postRequiringPermissionWithCSRF("/:id/respond", UserRole.Permission.MANAGE_INVITES, SettingsAdminRoutes::respondToSignupRequest); + }); + getRequiringPermission("/other", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::otherSettings); + postRequiringPermissionWithCSRF("/updateEmailSettings", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::saveEmailSettings); + postRequiringPermissionWithCSRF("/sendTestEmail", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::sendTestEmail); + getRequiringPermission("/auditLog", UserRole.Permission.VIEW_SERVER_AUDIT_LOG, SettingsAdminRoutes::auditLog); + path("/emailRules", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRules); + getRequiringPermission("/createForm", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRuleCreateForm); + postRequiringPermissionWithCSRF("/create", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRuleCreate); + path("/:domain", ()->{ + getRequiringPermission("/edit", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRuleEdit); + postRequiringPermissionWithCSRF("/update", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRuleUpdate); + getRequiringPermission("/confirmDelete", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRuleConfirmDelete); + postRequiringPermissionWithCSRF("/delete", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::emailDomainRuleDelete); + }); + }); + path("/ipRules", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRules); + getRequiringPermission("/createForm", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRuleCreateForm); + postRequiringPermissionWithCSRF("/create", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRuleCreate); + path("/:id", ()->{ + getRequiringPermission("/edit", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRuleEdit); + postRequiringPermissionWithCSRF("/update", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRuleUpdate); + getRequiringPermission("/confirmDelete", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRuleConfirmDelete); + postRequiringPermissionWithCSRF("/delete", UserRole.Permission.MANAGE_BLOCKING_RULES, SettingsAdminRoutes::ipRuleDelete); + }); + }); + path("/invites", ()->{ + getRequiringPermission("", UserRole.Permission.MANAGE_INVITES, SettingsAdminRoutes::invites); + path("/:id", ()->{ + getRequiringPermission("/confirmDelete", UserRole.Permission.MANAGE_INVITES, SettingsAdminRoutes::confirmDeleteInvite); + postRequiringPermissionWithCSRF("/delete", UserRole.Permission.MANAGE_INVITES, SettingsAdminRoutes::deleteInvite); + }); + }); }); }); @@ -283,7 +390,6 @@ public static void main(String[] args){ path("/system", ()->{ get("/downloadExternalMedia", SystemRoutes::downloadExternalMedia); - getWithCSRF("/deleteDraftAttachment", SystemRoutes::deleteDraftAttachment); path("/upload", ()->{ postWithCSRF("/postPhoto", SystemRoutes::uploadPostPhoto); postWithCSRF("/messagePhoto", SystemRoutes::uploadMessagePhoto); @@ -295,12 +401,19 @@ public static void main(String[] args){ getLoggedIn("/reportForm", SystemRoutes::reportForm); postWithCSRF("/submitReport", SystemRoutes::submitReport); get("/captcha", SystemRoutes::captcha); + if(Config.DEBUG){ + path("/debug", ()->{ + get("/deleteAbandonedFilesNow", (req, resp)->{ + MediaStorageUtils.deleteAbandonedFiles(); + return "ok"; + }); + }); + } }); path("/users/:id", ()->{ - getActivityPub("", ActivityPubRoutes::userActor); get("", (req, resp)->{ - int id=Utils.parseIntOrDefault(req.params(":id"), 0); + int id=parseIntOrDefault(req.params(":id"), 0); User user=UserStorage.getById(id); if(user==null || user instanceof ForeignUser){ throw new ObjectNotFoundException("err_user_not_found"); @@ -309,6 +422,7 @@ public static void main(String[] args){ } return ""; }); + getActivityPub("", ActivityPubRoutes::userActor); post("/inbox", ActivityPubRoutes::userInbox); get("/inbox", SmithereenApplication::methodNotAllowed); @@ -347,16 +461,25 @@ public static void main(String[] args){ postWithCSRF("/doRemoveFriend", FriendsRoutes::doRemoveFriend); getLoggedIn("/confirmRemoveFriend", FriendsRoutes::confirmRemoveFriend); - getRequiringAccessLevelWithCSRF("/syncRelCollections", Account.AccessLevel.ADMIN, ProfileRoutes::syncRelationshipsCollections); - getRequiringAccessLevelWithCSRF("/syncContentCollections", Account.AccessLevel.ADMIN, ProfileRoutes::syncContentCollections); - getRequiringAccessLevelWithCSRF("/syncProfile", Account.AccessLevel.ADMIN, ProfileRoutes::syncProfile); + getRequiringPermissionWithCSRF("/syncRelCollections", UserRole.Permission.MANAGE_USERS, ProfileRoutes::syncRelationshipsCollections); + getRequiringPermissionWithCSRF("/syncContentCollections", UserRole.Permission.MANAGE_USERS, ProfileRoutes::syncContentCollections); + getRequiringPermissionWithCSRF("/syncProfile", UserRole.Permission.MANAGE_USERS, ProfileRoutes::syncProfile); + getRequiringPermission("/meminfo", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::userInfo); + getRequiringPermission("/banForm", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::banUserForm); + postRequiringPermissionWithCSRF("/ban", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::banUser); + getRequiringPermission("/deleteImmediatelyForm", UserRole.Permission.DELETE_USERS_IMMEDIATE, SettingsAdminRoutes::deleteAccountImmediatelyForm); + postRequiringPermissionWithCSRF("/deleteImmediately", UserRole.Permission.DELETE_USERS_IMMEDIATE, SettingsAdminRoutes::deleteAccountImmediately); + getRequiringPermission("/reports", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportsOfUser); + getRequiringPermission("/reports/authored", UserRole.Permission.MANAGE_REPORTS, SettingsAdminRoutes::reportsByUser); + getRequiringPermission("/staffNotes", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::userStaffNotes); + postRequiringPermissionWithCSRF("/addStaffNote", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::userStaffNoteAdd); + getRequiringPermission("/staffNotes/:noteID/confirmDelete", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::userStaffNoteConfirmDelete); + postRequiringPermissionWithCSRF("/staffNotes/:noteID/delete", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::userStaffNoteDelete); }); path("/groups/:id", ()->{ - get("", "application/activity+json", ActivityPubRoutes::groupActor); - get("", "application/ld+json", ActivityPubRoutes::groupActor); get("", (req, resp)->{ - int id=Utils.parseIntOrDefault(req.params(":id"), 0); + int id=parseIntOrDefault(req.params(":id"), 0); Group group=GroupStorage.getById(id); if(group==null || group instanceof ForeignGroup){ throw new ObjectNotFoundException("err_group_not_found"); @@ -365,6 +488,8 @@ public static void main(String[] args){ } return ""; }); + get("", "application/activity+json", ActivityPubRoutes::groupActor); + get("", "application/ld+json", ActivityPubRoutes::groupActor); postWithCSRF("/createWallPost", PostRoutes::createGroupWallPost); @@ -417,15 +542,15 @@ public static void main(String[] args){ getWithCSRF("/invite", GroupsRoutes::inviteFriend); postWithCSRF("/respondToInvite", GroupsRoutes::respondToInvite); - getRequiringAccessLevelWithCSRF("/syncRelCollections", Account.AccessLevel.ADMIN, GroupsRoutes::syncRelationshipsCollections); - getRequiringAccessLevelWithCSRF("/syncContentCollections", Account.AccessLevel.ADMIN, GroupsRoutes::syncContentCollections); - getRequiringAccessLevelWithCSRF("/syncProfile", Account.AccessLevel.ADMIN, GroupsRoutes::syncProfile); + getRequiringPermissionWithCSRF("/syncRelCollections", UserRole.Permission.MANAGE_GROUPS, GroupsRoutes::syncRelationshipsCollections); + getRequiringPermissionWithCSRF("/syncContentCollections", UserRole.Permission.MANAGE_GROUPS, GroupsRoutes::syncContentCollections); + getRequiringPermissionWithCSRF("/syncProfile", UserRole.Permission.MANAGE_GROUPS, GroupsRoutes::syncProfile); }); path("/posts/:postID", ()->{ + get("", PostRoutes::standalonePost); getActivityPub("", ActivityPubRoutes::post); get("/activityCreate", ActivityPubRoutes::postCreateActivity); - get("", PostRoutes::standalonePost); getLoggedIn("/confirmDelete", PostRoutes::confirmDelete); postWithCSRF("/delete", PostRoutes::delete); @@ -481,7 +606,7 @@ public static void main(String[] args){ getLoggedIn("/history", MailRoutes::history); path("/messages/:id", ()->{ Filter idParserFilter=(req, resp)->{ - long id=Utils.decodeLong(req.params(":id")); + long id=decodeLong(req.params(":id")); if(id==0) throw new ObjectNotFoundException(); req.attribute("id", id); @@ -508,9 +633,9 @@ public static void main(String[] args){ get("/healthz", (req, resp)->""); path("/:username", ()->{ + get("", ProfileRoutes::profile); // These also handle groups getActivityPub("", ActivityPubRoutes::userActor); - get("", ProfileRoutes::profile); postWithCSRF("/remoteFollow", ActivityPubRoutes::remoteFollow); }); @@ -518,7 +643,7 @@ public static void main(String[] args){ exception(ObjectNotFoundException.class, (x, req, resp)->{ resp.status(404); - resp.body(Utils.wrapErrorString(req, resp, Objects.requireNonNullElse(x.getMessage(), "err_not_found"))); + resp.body(wrapErrorString(req, resp, Objects.requireNonNullElse(x.getMessage(), "err_not_found"))); }); exception(UserActionNotAllowedException.class, (x, req, resp)->{ if(Config.DEBUG) @@ -530,7 +655,7 @@ public static void main(String[] args){ }else{ key="err_access"; } - resp.body(Utils.wrapErrorString(req, resp, Objects.requireNonNullElse(x.getMessage(), key))); + resp.body(wrapErrorString(req, resp, Objects.requireNonNullElse(x.getMessage(), key))); }); exception(BadRequestException.class, (x, req, resp)->{ if(Config.DEBUG) @@ -544,10 +669,15 @@ public static void main(String[] args){ }); exception(FloodControlViolationException.class, (x, req, resp)->{ resp.status(429); - resp.body(Utils.wrapErrorString(req, resp, Objects.requireNonNullElse(x.getMessage(), "err_flood_control"))); + resp.body(wrapErrorString(req, resp, Objects.requireNonNullElse(x.getMessage(), "err_flood_control"))); }); exception(UserErrorException.class, (x, req, resp)->{ - resp.body(Utils.wrapErrorString(req, resp, x.getMessage())); + resp.body(wrapErrorString(req, resp, x.getMessage())); + }); + exception(InaccessibleProfileException.class, (x, req, resp)->{ + RenderedTemplateResponse model=new RenderedTemplateResponse("hidden_profile", req); + model.with("user", x.user); + resp.body(model.renderToString()); }); exception(Exception.class, (exception, req, res) -> { LOG.warn("Exception while processing {} {}", req.requestMethod(), req.raw().getPathInfo(), exception); @@ -563,7 +693,7 @@ public static void main(String[] args){ long t=(long)l; resp.header("X-Generated-In", (System.currentTimeMillis()-t)+""); } - if(req.attribute("isTemplate")!=null && !Utils.isAjax(req)){ + if(req.attribute("isTemplate")!=null && !isAjax(req)){ String cssName=req.attribute("mobile")!=null ? "mobile.css" : "desktop.css"; resp.header("Link", "; rel=preload; as=style, ; rel=preload; as=script"); resp.header("Vary", "User-Agent, Accept-Language"); @@ -593,11 +723,11 @@ public static void main(String[] args){ responseTypeSerializer(ActivityPubObject.class, (out, obj, req, resp) -> { resp.type(ActivityPub.CONTENT_TYPE); OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); - Utils.gson.toJson(obj.asRootActivityPubObject(Utils.context(req), ()->{ + gson.toJson(obj.asRootActivityPubObject(context(req), ()->{ if(req.headers("signature")!=null){ try{ Actor requester=ActivityPub.verifyHttpSignature(req, null); - Utils.context(req).getObjectLinkResolver().storeOrUpdateRemoteObject(requester); + context(req).getObjectLinkResolver().storeOrUpdateRemoteObject(requester); String requesterDomain=requester.domain; LOG.trace("Requester domain for {} is {}", req.pathInfo(), requesterDomain); return requesterDomain; @@ -618,10 +748,28 @@ public static void main(String[] args){ responseTypeSerializer(WebDeltaResponse.class, (out, obj, req, resp) -> { OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); - Utils.gson.toJson(obj.commands(), writer); + gson.toJson(obj.commands(), writer); writer.flush(); }); + addServletEventListener(new HttpSessionListener(){ + @Override + public void sessionDestroyed(HttpSessionEvent se){ + synchronized(SmithereenApplication.class){ + String sid=se.getSession().getId(); + int accountID=accountIdsBySession.getOrDefault(sid, 0); + if(accountID==0) + return; + Set sessions=sessionsByAccount.get(accountID); + if(sessions==null) + return; + sessions.remove(se.getSession()); + if(sessions.isEmpty()) + sessionsByAccount.remove(accountID); + } + } + }); + MaintenanceScheduler.runDaily(()->{ try{ SessionStorage.deleteExpiredEmailCodes(); @@ -631,10 +779,12 @@ public static void main(String[] args){ }); MaintenanceScheduler.runPeriodically(DatabaseConnectionManager::closeUnusedConnections, 10, TimeUnit.MINUTES); MaintenanceScheduler.runPeriodically(MailController::deleteRestorableMessages, 1, TimeUnit.HOURS); + MaintenanceScheduler.runPeriodically(MediaStorageUtils::deleteAbandonedFiles, 1, TimeUnit.HOURS); + MaintenanceScheduler.runPeriodically(UsersController::doPendingAccountDeletions, 1, TimeUnit.DAYS); Runtime.getRuntime().addShutdownHook(new Thread(()->{ LOG.info("Stopping Spark"); - Spark.awaitStop(); + awaitStop(); LOG.info("Stopped Spark"); // These try-catch blocks are needed because these classes might not have been loaded by the time the process is shut down, // and the JVM refuses to load any new classes from within a shutdown hook. @@ -653,17 +803,18 @@ public static void main(String[] args){ } private static Object indexPage(Request req, Response resp){ - SessionInfo info=Utils.sessionInfo(req); + SessionInfo info=sessionInfo(req); if(info!=null && info.account!=null){ resp.redirect("/feed"); return ""; } + Config.SignupMode signupMode=context(req).getModerationController().getEffectiveSignupMode(req); RenderedTemplateResponse model=new RenderedTemplateResponse("index", req).with("title", Config.serverDisplayName) - .with("signupMode", Config.signupMode) + .with("signupMode", signupMode) .with("serverDisplayName", Config.serverDisplayName) .with("serverDescription", Config.serverDescription) - .addNavBarItem(Utils.lang(req).get("index_welcome")); - if((Config.signupMode==Config.SignupMode.OPEN || Config.signupMode==Config.SignupMode.MANUAL_APPROVAL) && Config.signupFormUseCaptcha){ + .addNavBarItem(lang(req).get("index_welcome")); + if((signupMode==Config.SignupMode.OPEN || signupMode==Config.SignupMode.MANUAL_APPROVAL) && Config.signupFormUseCaptcha){ model.with("captchaSid", randomAlphanumericString(16)); } return model; @@ -677,4 +828,66 @@ private static Object methodNotAllowed(Request req, Response resp){ private static void setupCustomSerializer(){ getSerializerChain().insertBeforeRoot(new ExtendedStreamingSerializer()); } + + private static boolean isAllowedForRestrictedAccounts(Request req){ + String path=req.pathInfo(); + return Set.of( + "/account/logout", + "/account/resendConfirmationEmail", + "/account/changeEmailForm", + "/account/changeEmail", + "/account/activate", + "/account/unfreezeBox", + "/account/unfreeze", + "/account/unfreezeChangePassword", + "/account/reactivateBox", + "/account/reactivate" + ).contains(path); + } + + private static void enforceAccountLimitationsIfAny(Request req, Response resp){ + if(isAllowedForRestrictedAccounts(req)) + return; + SessionInfo info=sessionInfo(req); + if(info!=null && info.account!=null){ + Account acc=info.account; + // Mandatory email confirmation + if(acc.activationInfo!=null && acc.activationInfo.emailState==Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED){ + Lang l=lang(req); + halt(new RenderedTemplateResponse("email_confirm_required", req).with("email", acc.email).pageTitle(l.get("account_activation")).renderToString()); + return; + } + // Account ban or self-deactivation + UserBanStatus status=info.account.user.banStatus; + if(status==UserBanStatus.NONE || status==UserBanStatus.HIDDEN) + return; + Lang l=lang(req); + RenderedTemplateResponse model=new RenderedTemplateResponse("account_banned", req); + model.pageTitle(l.get(switch(status){ + case FROZEN -> "account_frozen"; + case SUSPENDED -> "account_suspended"; + case SELF_DEACTIVATED -> "account_deactivated"; + default -> throw new IllegalStateException("Unexpected value: " + status); + })); + model.with("status", status).with("banInfo", acc.user.banInfo).with("contactEmail", Config.serverAdminEmail); + switch(status){ + case FROZEN -> { + if(acc.user.banInfo.expiresAt().isAfter(Instant.now())){ + model.with("unfreezeTime", acc.user.banInfo.expiresAt()); + } + } + case SUSPENDED, SELF_DEACTIVATED -> model.with("deletionTime", acc.user.banInfo.bannedAt().plus(30, ChronoUnit.DAYS)); + } + halt(model.renderToString()); + } + } + + public static synchronized void invalidateAllSessionsForAccount(int id){ + Set sessions=sessionsByAccount.get(id); + if(sessions==null) + return; + for(HttpSession session:new HashSet<>(sessions)){ + session.invalidate(); + } + } } diff --git a/src/main/java/smithereen/Utils.java b/src/main/java/smithereen/Utils.java index bb378cc6..9698f247 100644 --- a/src/main/java/smithereen/Utils.java +++ b/src/main/java/smithereen/Utils.java @@ -23,12 +23,19 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.IDN; +import java.net.Inet4Address; +import java.net.Inet6Address; import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.sql.SQLException; import java.time.Instant; import java.time.LocalDate; @@ -37,9 +44,11 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.BitSet; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; @@ -52,6 +61,7 @@ import java.util.Random; import java.util.Set; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.regex.Matcher; @@ -61,12 +71,13 @@ import cz.jirutka.unidecode.Unidecode; import smithereen.activitypub.objects.Actor; -import smithereen.model.Account; +import smithereen.exceptions.UserErrorException; +import smithereen.model.CaptchaInfo; import smithereen.model.ForeignUser; import smithereen.model.Group; import smithereen.model.SessionInfo; import smithereen.model.StatsPoint; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.model.WebDeltaResponse; import smithereen.exceptions.BadRequestException; @@ -76,6 +87,8 @@ import smithereen.storage.GroupStorage; import smithereen.storage.UserStorage; import smithereen.templates.RenderedTemplateResponse; +import smithereen.util.EmailCodeActionType; +import smithereen.util.FloodControl; import smithereen.util.InstantMillisJsonAdapter; import smithereen.util.JsonArrayBuilder; import smithereen.util.JsonObjectBuilder; @@ -132,17 +145,6 @@ public static String csrfTokenFromSessionID(byte[] sid){ return String.format(Locale.ENGLISH, "%08x%08x", v1, v2); } - private static boolean isAllowedForRestrictedAccounts(Request req){ - String path=req.pathInfo(); - return List.of( - "/account/logout", - "/account/resendConfirmationEmail", - "/account/changeEmailForm", - "/account/changeEmail", - "/account/activate" - ).contains(path); - } - public static boolean requireAccount(Request req, Response resp){ if(req.session(false)==null || req.session().attribute("info")==null || ((SessionInfo)req.session().attribute("info")).account==null){ String to=req.pathInfo(); @@ -152,19 +154,6 @@ public static boolean requireAccount(Request req, Response resp){ resp.redirect("/account/login?to="+URLEncoder.encode(to)); return false; } - Account acc=sessionInfo(req).account; - if(acc.banInfo!=null && !isAllowedForRestrictedAccounts(req)){ - Lang l=lang(req); - String msg=l.get("your_account_is_banned"); - if(StringUtils.isNotEmpty(acc.banInfo.reason)) - msg+="\n\n"+l.get("ban_reason")+": "+acc.banInfo.reason; - resp.body(new RenderedTemplateResponse("generic_message", req).with("message", msg).renderToString()); - return false; - }else if(acc.activationInfo!=null && acc.activationInfo.emailState==Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED && !isAllowedForRestrictedAccounts(req)){ - Lang l=lang(req); - resp.body(new RenderedTemplateResponse("email_confirm_required", req).with("email", acc.email).pageTitle(l.get("account_activation")).renderToString()); - return false; - } return true; } @@ -243,7 +232,11 @@ public static Object wrapForm(Request req, Response resp, String templateName, S } } - public static Object wrapForm(Request req, Response resp, String templateName, String formAction, String title, String buttonKey, String formID, List fieldNames, Function fieldValueGetter, String message){ + public static Object wrapForm(Request req, Response resp, String templateName, String formAction, String title, String buttonKey, String formID, List fieldNames, Function fieldValueGetter, String message){ + return wrapForm(req, resp, templateName, formAction, title, buttonKey, formID, fieldNames, fieldValueGetter, message, null); + } + + public static Object wrapForm(Request req, Response resp, String templateName, String formAction, String title, String buttonKey, String formID, List fieldNames, Function fieldValueGetter, String message, Map extraTemplateArgs){ if(isAjax(req) && StringUtils.isNotEmpty(message)){ WebDeltaResponse wdr=new WebDeltaResponse(resp); wdr.keepBox().show("formMessage_"+formID).setContent("formMessage_"+formID, escapeHTML(message)); @@ -259,13 +252,18 @@ public static Object wrapForm(Request req, Response resp, String templateName, S for(String name:fieldNames){ model.with(name, fieldValueGetter.apply(name)); } + if(extraTemplateArgs!=null){ + for(Map.Entry e:extraTemplateArgs.entrySet()){ + model.with(e.getKey(), e.getValue()); + } + } return wrapForm(req, resp, templateName, formAction, title, buttonKey, model); } public static String requireFormField(Request req, String field, String errorKey){ String value=req.queryParams(field); if(StringUtils.isEmpty(value)) - throw new FormValidationException(lang(req).get(errorKey)); + throw new FormValidationException(errorKey==null ? ("Required field missing: "+field) : lang(req).get(errorKey)); return value; } @@ -276,7 +274,22 @@ public static String requireFormFieldLength(Request req, String field, int minLe return value; } + public static > E requireFormField(Request req, String field, String errorKey, Class enumClass){ + String value=req.queryParams(field); + if(StringUtils.isEmpty(value)) + throw new FormValidationException(errorKey==null ? ("Required field missing: "+field) : lang(req).get(errorKey)); + try{ + return Enum.valueOf(enumClass, value); + }catch(IllegalArgumentException x){ + throw new FormValidationException(errorKey==null ? ("Required field missing: "+field) : lang(req).get(errorKey)); + } + } + public static Locale localeForRequest(Request req){ + String langParam=req.queryParams("lang"); + if(StringUtils.isNotEmpty(langParam)){ + return Locale.forLanguageTag(langParam); + } SessionInfo info=sessionInfo(req); if(info!=null){ if(info.account!=null && info.account.prefs.locale!=null) @@ -379,7 +392,7 @@ public void tail(Node node, int depth){ } } - private class ListNodeInfo{ + private static class ListNodeInfo{ final boolean isOrdered; final Element element; int currentIndex=1; @@ -391,6 +404,7 @@ private ListNodeInfo(boolean isOrdered, Element element){ } }); doc.getElementsByTag("li").forEach(Element::unwrap); + doc.getElementsByClass("smithereenPollQuestion").forEach(Element::remove); doc.normalise(); return cleaner.clean(doc).body().html(); } @@ -576,7 +590,7 @@ private static void makeLinksAndMentions(Node node, @Nullable MentionCallback me if(el.tagName().equalsIgnoreCase("pre")){ return; }else if(el.tagName().equalsIgnoreCase("a")){ - if(el.hasClass("mention")){ + if(el.hasClass("mention") && !el.hasAttr("data-user-id")){ User user=mentionCallback==null ? null : mentionCallback.resolveMention(el.attr("href")); if(user==null){ el.removeClass("mention"); @@ -765,6 +779,9 @@ public static String postprocessPostHTMLForDisplay(String text){ User user=UserStorage.getById(uid); if(user!=null){ el.attr("href", "/"+user.getFullUsername()); + if(user instanceof ForeignUser){ + el.attr("rel", "nofollow"); + } el.addClass("u-url"); Element parent=el.parent(); if(parent==null || !parent.tagName().equalsIgnoreCase("span")){ @@ -779,7 +796,7 @@ public static String postprocessPostHTMLForDisplay(String text){ URI uri=new URI(href); if(uri.isAbsolute() && !Config.isLocal(uri)){ el.attr("target", "_blank"); - el.attr("rel", "noopener"); + el.attr("rel", "noopener ugc"); } }catch(URISyntaxException x){} } @@ -974,6 +991,20 @@ public static > void deserializeEnumSet(EnumSet set, Class< } } + public static > byte[] serializeEnumSetToBytes(EnumSet set){ + BitSet result=new BitSet(); + for(E value:set){ + result.set(value.ordinal()); + } + return result.toByteArray(); + } + + public static > void deserializeEnumSet(EnumSet set, Class cls, byte[] serialized){ + set.clear(); + E[] consts=cls.getEnumConstants(); + BitSet.valueOf(serialized).stream().mapToObj(i->consts[i]).forEach(set::add); + } + /** * Convert a string to an enum value * @param val @@ -1134,8 +1165,112 @@ public static void deserializeLongCollection(byte[] b, Collection dest){ } } + /** + * Serialize an {@link InetAddress} for storage in the database. + * No reverse method exists because {@link InetAddress#getByAddress(byte[])} takes IPv4-mapped IPv6 addresses and returns an Inet4Address. + * Actually no, it exists now because checked exceptions are a pain in the ass + * @param ip + * @return 16 bytes. IPv6 addresses are returned as-is, IPv4 are mapped into IPv6 (::ffff:x.x.x.x) + */ + public static byte[] serializeInetAddress(InetAddress ip){ + return switch(ip){ + case null -> null; + case Inet4Address ipv4 -> { + byte[] a=new byte[16]; + a[11]=a[10]=-1; + System.arraycopy(ipv4.getAddress(), 0, a, 12, 4); + yield a; + } + case Inet6Address ipv6 -> ipv6.getAddress(); + default -> throw new IllegalStateException("Unexpected value: "+ip); // TODO why is this required for a sealed hierarchy? + }; + } + + public static InetAddress deserializeInetAddress(byte[] ip){ + if(ip==null) + return null; + try{ + return InetAddress.getByAddress(ip); + }catch(UnknownHostException e){ + throw new IllegalArgumentException(e); + } + } + + public static long hashUserAgent(String ua){ + try{ + MessageDigest md5=MessageDigest.getInstance("MD5"); + byte[] hash=md5.digest(ua.getBytes(StandardCharsets.UTF_8)); + return unpackLong(hash, 0) ^ unpackLong(hash, 8); + }catch(NoSuchAlgorithmException x){ + throw new RuntimeException(x); + } + } + + public static Object sendEmailConfirmationCode(Request req, Response resp, EmailCodeActionType type, String formAction){ + SessionInfo info=Objects.requireNonNull(sessionInfo(req)); + FloodControl.ACTION_CONFIRMATION.incrementOrThrow(info.account); + Random rand=ThreadLocalRandom.current(); + char[] _code=new char[5]; + for(int i=0;i<_code.length;i++){ + _code[i]=(char)('0'+rand.nextInt(10)); + } + String code=new String(_code); + req.session().attribute("emailCodeInfo", new EmailConfirmationCodeInfo(code, type, Instant.now())); + Mailer.getInstance().sendActionConfirmationCode(req, info.account, lang(req).get(type.actionLangKey()), code); + RenderedTemplateResponse model=new RenderedTemplateResponse("email_confirmation_code_form", req); + model.with("maskedEmail", info.account.getCurrentEmailMasked()).with("action", type.actionLangKey()); + return wrapForm(req, resp, "email_confirmation_code_form", formAction, lang(req).get("action_confirmation"), "next", model); + } + + public static void checkEmailConfirmationCode(Request req, EmailCodeActionType type){ + EmailConfirmationCodeInfo info=req.session().attribute("emailCodeInfo"); + req.session().removeAttribute("emailCodeInfo"); + if(info==null || info.actionType!=type || !Objects.equals(info.code, req.queryParams("code")) || info.sentAt.plus(10, ChronoUnit.MINUTES).isBefore(Instant.now())) + throw new UserErrorException("action_confirmation_incorrect_code"); + } + + public static void copyBytes(InputStream from, OutputStream to) throws IOException{ + byte[] buffer=new byte[10240]; + int read; + while((read=from.read(buffer))>0){ + to.write(buffer, 0, read); + } + } + + public static Object ajaxAwareRedirect(Request req, Response resp, String destination){ + if(isAjax(req)) + return new WebDeltaResponse(resp).replaceLocation(destination); + resp.redirect(destination); + return ""; + } + + public static Object wrapConfirmation(Request req, Response resp, String title, String message, String action){ + if(isAjax(req)){ + return new WebDeltaResponse(resp).confirmBox(title, message, action); + } + req.attribute("noHistory", true); + Lang l=lang(req); + String back=back(req); + return new RenderedTemplateResponse("generic_confirm", req).with("message", message).with("formAction", action).with("back", back).pageTitle(title); + } + + public static void verifyCaptcha(Request req){ + String captcha=requireFormField(req, "captcha", "err_wrong_captcha"); + String sid=requireFormField(req, "captchaSid", "err_wrong_captcha"); + LruCache captchas=req.session().attribute("captchas"); + if(captchas==null) + throw new UserErrorException("err_wrong_captcha"); + CaptchaInfo info=captchas.remove(sid); + if(info==null) + throw new UserErrorException("err_wrong_captcha"); + if(!info.answer().equals(captcha) || System.currentTimeMillis()-info.generatedAt().toEpochMilli()<3000) + throw new UserErrorException("err_wrong_captcha"); + } + public interface MentionCallback{ User resolveMention(String username, String domain); User resolveMention(String uri); } + + private record EmailConfirmationCodeInfo(String code, EmailCodeActionType actionType, Instant sentAt){} } diff --git a/src/main/java/smithereen/activitypub/ActivityPub.java b/src/main/java/smithereen/activitypub/ActivityPub.java index b03d08de..e091e37f 100644 --- a/src/main/java/smithereen/activitypub/ActivityPub.java +++ b/src/main/java/smithereen/activitypub/ActivityPub.java @@ -7,6 +7,8 @@ import com.google.gson.JsonParser; import org.jetbrains.annotations.NotNull; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; @@ -16,7 +18,14 @@ import org.xml.sax.SAXException; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.MessageDigest; @@ -42,73 +51,66 @@ import java.util.stream.Collectors; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import okhttp3.Call; -import okhttp3.FormBody; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; + import smithereen.ApplicationContext; import smithereen.Config; -import smithereen.activitypub.objects.CollectionQueryResult; -import smithereen.model.FederationRestriction; -import smithereen.model.ForeignGroup; -import smithereen.model.Group; -import smithereen.model.Server; -import smithereen.model.StatsType; -import smithereen.exceptions.FederationException; -import smithereen.exceptions.InternalServerErrorException; -import smithereen.exceptions.UserActionNotAllowedException; -import smithereen.storage.GroupStorage; -import smithereen.util.DisallowLocalhostInterceptor; import smithereen.LruCache; import smithereen.Utils; import smithereen.activitypub.objects.Activity; import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.Actor; +import smithereen.activitypub.objects.CollectionQueryResult; import smithereen.activitypub.objects.ServiceActor; import smithereen.activitypub.objects.WebfingerResponse; -import smithereen.model.UriBuilder; import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.FederationException; +import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.exceptions.UnsupportedRemoteObjectTypeException; +import smithereen.exceptions.UserActionNotAllowedException; +import smithereen.http.ExtendedHttpClient; +import smithereen.http.FormBodyPublisherBuilder; +import smithereen.http.HttpContentType; +import smithereen.http.ReaderBodyHandler; import smithereen.jsonld.JLD; import smithereen.jsonld.JLDException; import smithereen.jsonld.JLDProcessor; import smithereen.jsonld.LinkedDataSignatures; +import smithereen.model.FederationRestriction; +import smithereen.model.ForeignGroup; +import smithereen.model.Group; +import smithereen.model.Server; +import smithereen.model.StatsType; +import smithereen.storage.GroupStorage; import smithereen.util.JsonArrayBuilder; import smithereen.util.JsonObjectBuilder; -import smithereen.util.UserAgentInterceptor; +import smithereen.util.UriBuilder; +import smithereen.util.XmlParser; import spark.utils.StringUtils; -import static smithereen.Utils.context; -import static smithereen.Utils.formatDateAsISO; -import static smithereen.Utils.parseSignatureHeader; +import static smithereen.Utils.*; public class ActivityPub{ public static final URI AS_PUBLIC=URI.create(JLD.ACTIVITY_STREAMS+"#Public"); public static final String CONTENT_TYPE="application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; + public static final HttpContentType EXPECTED_CONTENT_TYPE=new HttpContentType("application/ld+json", Map.of("profile", "https://www.w3.org/ns/activitystreams")); private static final Logger LOG=LoggerFactory.getLogger(ActivityPub.class); - public static final OkHttpClient httpClient; + public static final HttpClient httpClient; private static LruCache domainRedirects=new LruCache<>(100); static{ - httpClient=new OkHttpClient.Builder() - .addNetworkInterceptor(new DisallowLocalhostInterceptor()) - .addNetworkInterceptor(new UserAgentInterceptor()) -// .addNetworkInterceptor(new LoggingInterceptor()) + httpClient=ExtendedHttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) .build(); } public static ActivityPubObject fetchRemoteObject(URI _uri, Actor signer, JsonObject actorToken, ApplicationContext ctx) throws IOException{ + return fetchRemoteObjectInternal(_uri, signer, actorToken, ctx, true); + } + + private static ActivityPubObject fetchRemoteObjectInternal(URI _uri, Actor signer, JsonObject actorToken, ApplicationContext ctx, boolean tryHTML) throws IOException{ LOG.trace("Fetching remote object from {}", _uri); URI uri; String token; @@ -135,26 +137,54 @@ public static ActivityPubObject fetchRemoteObject(URI _uri, Actor signer, JsonOb } } - Request.Builder builder=new Request.Builder() - .url(uri.toString()) + HttpRequest.Builder builder=HttpRequest.newBuilder(uri) .header("Accept", CONTENT_TYPE); if(token!=null) builder.header("Authorization", "Bearer "+token); else if(actorToken!=null) builder.header("Authorization", "ActivityPubActorToken "+actorToken); signRequest(builder, uri, signer==null ? ServiceActor.getInstance() : signer, null, "get"); - Request req=builder.build(); - Call call=httpClient.newCall(req); - Response resp=call.execute(); - try(ResponseBody body=resp.body()){ - if(!resp.isSuccessful()){ - throw new ObjectNotFoundException("Response is not successful: remote server returned "+resp.code()+" "+resp.message()); + HttpResponse resp; + try{ + resp=httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + }catch(InterruptedException x){ + throw new RuntimeException(x); + } + if(resp.statusCode()/100!=2){ + try(InputStream in=resp.body()){ + while(in.skip(8192)>0L); + } + throw new ObjectNotFoundException("Response is not successful: remote server returned "+resp.statusCode()); + } + HttpContentType contentType=HttpContentType.from(resp.headers()); + try(InputStream in=resp.body()){ + if(tryHTML && contentType.matches("text/html")){ + LOG.trace("Received HTML, trying to extract "); + org.jsoup.nodes.Document doc=Jsoup.parse(in, contentType.getCharset().name(), uri.toString()); + for(Element el:doc.select("link[rel=alternate]")){ + LOG.trace("Candidate element: {}", el); + String type=el.attr("type"); + if("application/activity+json".equals(type) || CONTENT_TYPE.equals(type)){ + String url=el.absUrl("href"); + LOG.trace("Will follow redirect: {}", url); + if(StringUtils.isNotEmpty(url)){ + try{ + return fetchRemoteObjectInternal(UriBuilder.parseAndEncode(url), signer, actorToken, ctx, false); + }catch(URISyntaxException x){ + throw new ObjectNotFoundException("Failed to parse URL from ", x); + } + } + } + } + } + // Allow "application/activity+json" or "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + if(!contentType.matches("application/activity+json") && !contentType.matches(EXPECTED_CONTENT_TYPE)){ + throw new ObjectNotFoundException("Invalid Content-Type: "+contentType); } try{ - JsonElement el=JsonParser.parseReader(body.charStream()); + JsonElement el=JsonParser.parseReader(new InputStreamReader(in, contentType.getCharset())); JsonObject converted=JLDProcessor.convertToLocalContext(el.getAsJsonObject()); -// System.out.println(converted.toString(4)); ActivityPubObject obj=ActivityPubObject.parse(converted); if(obj==null) throw new UnsupportedRemoteObjectTypeException("Unsupported object type "+converted.get("type")); @@ -168,7 +198,7 @@ else if(actorToken!=null) } } - private static Request.Builder signRequest(Request.Builder builder, URI url, Actor actor, byte[] body, String method){ + private static HttpRequest.Builder signRequest(HttpRequest.Builder builder, URI url, Actor actor, byte[] body, String method){ String path=url.getPath(); String host=url.getHost(); if(url.getPort()!=-1) @@ -219,19 +249,19 @@ public static void postActivity(URI inboxUrl, Activity activity, Actor actor, Ap Server server=ctx.getModerationController().getServerByDomain(inboxUrl.getAuthority()); if(server.getAvailability()==Server.Availability.DOWN){ - LOG.info("Not sending {} activity to server {} because it's down", activity.getType(), server.host()); + LOG.debug("Not sending {} activity to server {} because it's down", activity.getType(), server.host()); return; } if(server.restriction()!=null){ if(server.restriction().type==FederationRestriction.RestrictionType.SUSPENSION){ - LOG.info("Not sending {} activity to server {} because federation with it is blocked", activity.getType(), server.host()); + LOG.debug("Not sending {} activity to server {} because federation with it is blocked", activity.getType(), server.host()); return; } } JsonObject body=activity.asRootActivityPubObject(ctx, inboxUrl.getAuthority()); LinkedDataSignatures.sign(body, actor.privateKey, actor.activityPubID+"#main-key"); - LOG.info("Sending activity: {}", body); + LOG.debug("Sending activity: {}", body); postActivityInternal(inboxUrl, body.toString(), actor, server, ctx, isRetry); } @@ -241,12 +271,12 @@ public static void postActivity(URI inboxUrl, String activityJson, Actor actor, Server server=ctx.getModerationController().getServerByDomain(inboxUrl.getAuthority()); if(server.getAvailability()==Server.Availability.DOWN){ - LOG.info("Not forwarding activity to server {} because it's down", server.host()); + LOG.debug("Not forwarding activity to server {} because it's down", server.host()); return; } if(server.restriction()!=null){ if(server.restriction().type==FederationRestriction.RestrictionType.SUSPENSION){ - LOG.info("Not forwarding activity to server {} because federation with it is blocked", server.host()); + LOG.debug("Not forwarding activity to server {} because federation with it is blocked", server.host()); return; } } @@ -259,24 +289,22 @@ private static void postActivityInternal(URI inboxUrl, String activityJson, Acto throw new IllegalArgumentException("Sending an activity requires an actor that has a private key on this server."); byte[] body=activityJson.getBytes(StandardCharsets.UTF_8); - Request req=signRequest( - new Request.Builder() - .url(inboxUrl.toString()) - .post(RequestBody.create(MediaType.parse("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""), body)), + HttpRequest req=signRequest( + HttpRequest.newBuilder(inboxUrl) + .header("Content-Type", CONTENT_TYPE) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)), inboxUrl, actor, body, "post") .build(); try{ - Response resp=httpClient.newCall(req).execute(); - LOG.info("Post activity response: {}", resp); - try(ResponseBody rb=resp.body()){ - if(!resp.isSuccessful()){ - LOG.info("Response body: {}", rb.string()); - if(resp.code()!=403){ - if(resp.code()/100==5){ // IOException does trigger retrying, FederationException does not. We want retries for 5xx (server) errors. - throw new IOException("Response is not successful: "+resp.code()); - }else{ - throw new FederationException("Response is not successful: "+resp.code()); - } + HttpResponse resp=httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + LOG.debug("Post activity response: {}", resp); + if(resp.statusCode()/100!=2){ + LOG.debug("Response body: {}", resp.body()); + if(resp.statusCode()!=403){ + if(resp.statusCode()/100==5){ // IOException does trigger retrying, FederationException does not. We want retries for 5xx (server) errors. + throw new IOException("Response is not successful: "+resp.statusCode()); + }else{ + throw new FederationException("Response is not successful: "+resp.statusCode()); } } } @@ -290,7 +318,7 @@ private static void postActivityInternal(URI inboxUrl, String activityJson, Acto ctx.getStatsController().incrementDaily(StatsType.SERVER_ACTIVITIES_FAILED_ATTEMPTS, server.id()); } throw x; - } + }catch(InterruptedException ignored){} } public static boolean isPublic(URI uri){ @@ -299,29 +327,38 @@ public static boolean isPublic(URI uri){ private static URI doWebfingerRequest(String username, String domain, String uriTemplate) throws IOException{ String resource="acct:"+username+"@"+domain; - String url; + URI url; if(StringUtils.isEmpty(uriTemplate)){ - url=(Config.useHTTP ? "http" : "https")+"://"+domain+"/.well-known/webfinger?resource="+resource; + url=new UriBuilder() + .scheme(Config.useHTTP ? "http" : "https") + .authority(domain) + .path(".well-known", "webfinger") + .queryParam("resource", resource) + .build(); }else{ - url=uriTemplate.replace("{uri}", resource); + url=URI.create(uriTemplate.replace("{uri}", resource)); } - Request req=new Request.Builder() - .url(url) - .build(); - Response resp=httpClient.newCall(req).execute(); - try(ResponseBody body=resp.body()){ - if(resp.isSuccessful()){ - WebfingerResponse wr=Utils.gson.fromJson(body.charStream(), WebfingerResponse.class); + HttpRequest req=HttpRequest.newBuilder(url).build(); + HttpResponse resp; + try{ + resp=httpClient.send(req, new ReaderBodyHandler()); + }catch(InterruptedException e){ + throw new RuntimeException(e); + } + try(Reader reader=resp.body()){ + if(resp.statusCode()/100==2){ + WebfingerResponse wr=Utils.gson.fromJson(reader, WebfingerResponse.class); if(!resource.equalsIgnoreCase(wr.subject)) throw new IOException("Invalid response"); for(WebfingerResponse.Link link:wr.links){ - if("self".equals(link.rel) && "application/activity+json".equals(link.type) && link.href!=null){ + if("self".equals(link.rel) && ("application/activity+json".equals(link.type) || CONTENT_TYPE.equals(link.type)) && link.href!=null){ + LOG.trace("Successfully resolved {}@{} to {}", username, domain, link.href); return link.href; } } throw new IOException("Link not found"); - }else if(resp.code()==404){ + }else if(resp.statusCode()==404){ throw new ObjectNotFoundException("User "+username+"@"+domain+" does not exist"); }else{ throw new IOException("Failed to resolve username "+username+"@"+domain); @@ -355,18 +392,19 @@ public static URI resolveUsername(String username, String domain) throws IOExcep return uri; }catch(ObjectNotFoundException x){ if(redirect==null){ - Request req=new Request.Builder() - .url((Config.useHTTP ? "http" : "https")+"://"+domain+"/.well-known/host-meta") + HttpRequest req=HttpRequest.newBuilder(new UriBuilder().scheme(Config.useHTTP ? "http" : "https").authority(domain).path(".well-known", "host-meta").build()) .header("Accept", "application/xrd+xml") .build(); - Response resp=httpClient.newCall(req).execute(); - try(ResponseBody body=resp.body()){ - if(resp.isSuccessful()){ - DocumentBuilderFactory factory=DocumentBuilderFactory.newInstance(); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setXIncludeAware(false); - DocumentBuilder builder=factory.newDocumentBuilder(); - Document doc=builder.parse(body.byteStream()); + HttpResponse resp; + try{ + resp=httpClient.send(req, HttpResponse.BodyHandlers.ofInputStream()); + }catch(InterruptedException e){ + throw new RuntimeException(e); + } + try(InputStream in=resp.body()){ + if(resp.statusCode()/100==2){ + DocumentBuilder builder=XmlParser.newDocumentBuilder(); + Document doc=builder.parse(in); NodeList nodes=doc.getElementsByTagName("Link"); for(int i=0; i {}", domain, template); + LOG.debug("Found domain redirect: {} -> {}", domain, template); domainRedirects.put(domain, template); } } @@ -401,7 +439,7 @@ public static URI resolveUsername(String username, String domain) throws IOExcep } } } - }catch(ParserConfigurationException|SAXException e){ + }catch(SAXException e){ throw new ObjectNotFoundException("Webfinger returned 404 and host-meta can't be parsed", e); } } @@ -476,8 +514,8 @@ public static Actor verifyHttpSignature(spark.Request req, Actor userHint) throw sig.initVerify(user.publicKey); sig.update(sigStr.getBytes(StandardCharsets.UTF_8)); if(!sig.verify(signature)){ - LOG.info("Failed signature header: {}", sigHeader); - LOG.info("Failed signature string: '{}'", sigStr); + LOG.debug("Failed signature header: {}", sigHeader); + LOG.debug("Failed signature string: '{}'", sigStr); throw new BadRequestException("Signature failed to verify"); } return user; @@ -584,17 +622,18 @@ public static void verifyActorToken(@NotNull JsonObject token, @NotNull Actor us public static JsonObject fetchActorToken(@NotNull ApplicationContext context, @NotNull Actor actor, @NotNull ForeignGroup group){ String url=Objects.requireNonNull(group.actorTokenEndpoint).toString(); - Request.Builder builder=new Request.Builder() - .url(url); + HttpRequest.Builder builder=HttpRequest.newBuilder(URI.create(url)); signRequest(builder, group.actorTokenEndpoint, actor, null, "get"); - Call call=httpClient.newCall(builder.build()); - try(Response resp=call.execute()){ - if(resp.isSuccessful()){ - JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject(); - verifyActorToken(obj, actor, group); - return obj; - }else{ - LOG.warn("Response for actor token for user {} in group {} was not successful: {}", actor.activityPubID, group.activityPubID, resp); + try{ + HttpResponse resp=httpClient.send(builder.build(), new ReaderBodyHandler()); + try(Reader reader=resp.body()){ + if(resp.statusCode()/100==2){ + JsonObject obj=JsonParser.parseReader(reader).getAsJsonObject(); + verifyActorToken(obj, actor, group); + return obj; + }else{ + LOG.warn("Response for actor token for user {} in group {} was not successful: {}", actor.activityPubID, group.activityPubID, resp); + } } }catch(Exception x){ LOG.warn("Error fetching actor token for user {} in group {}", actor.activityPubID, group.activityPubID, x); @@ -610,30 +649,32 @@ public static CollectionQueryResult performCollectionQuery(@NotNull Actor actor, throw new IllegalArgumentException("Collection ID and actor ID hostnames don't match"); if(query.isEmpty()) throw new IllegalArgumentException("Query is empty"); - Request.Builder builder=new Request.Builder() - .url(HttpUrl.get(actor.collectionQueryEndpoint.toString())); - FormBody.Builder body=new FormBody.Builder().add("collection", collectionID.toString()); + HttpRequest.Builder builder=HttpRequest.newBuilder(actor.collectionQueryEndpoint); + FormBodyPublisherBuilder body=new FormBodyPublisherBuilder().add("collection", collectionID.toString()); for(URI uri:query) body.add("item", uri.toString()); - builder.post(body.build()); + builder.POST(body.build()).header("Content-Type", FormBodyPublisherBuilder.CONTENT_TYPE); signRequest(builder, actor.collectionQueryEndpoint, actor, null, "post"); - try(Response resp=httpClient.newCall(builder.build()).execute()){ - if(resp.isSuccessful()){ - JsonElement el=JsonParser.parseReader(resp.body().charStream()); - JsonObject converted=JLDProcessor.convertToLocalContext(el.getAsJsonObject()); - ActivityPubObject aobj=ActivityPubObject.parse(converted); - if(aobj==null) - throw new UnsupportedRemoteObjectTypeException("Unsupported object type "+converted.get("type")); - if(aobj instanceof CollectionQueryResult cqr){ - if(!collectionID.equals(cqr.partOf)) - throw new FederationException("part_of in the collection query result '"+cqr.partOf+"' does not match expected '"+collectionID+"'"); - return cqr; + try{ + HttpResponse resp=httpClient.send(builder.build(), new ReaderBodyHandler()); + try(Reader reader=resp.body()){ + if(resp.statusCode()/100==2){ + JsonElement el=JsonParser.parseReader(reader); + JsonObject converted=JLDProcessor.convertToLocalContext(el.getAsJsonObject()); + ActivityPubObject aobj=ActivityPubObject.parse(converted); + if(aobj==null) + throw new UnsupportedRemoteObjectTypeException("Unsupported object type "+converted.get("type")); + if(aobj instanceof CollectionQueryResult cqr){ + if(!collectionID.equals(cqr.partOf)) + throw new FederationException("part_of in the collection query result '"+cqr.partOf+"' does not match expected '"+collectionID+"'"); + return cqr; + }else{ + throw new UnsupportedRemoteObjectTypeException("Expected object of type sm:CollectionQueryResult, got "+aobj.getType()); + } }else{ - throw new UnsupportedRemoteObjectTypeException("Expected object of type sm:CollectionQueryResult, got "+aobj.getType()); + LOG.warn("Response for collection query {} was not successful: {}", collectionID, resp); + return CollectionQueryResult.empty(collectionID); } - }else{ - LOG.warn("Response for collection query {} was not successful: {}", collectionID, resp); - return CollectionQueryResult.empty(collectionID); } }catch(Exception x){ LOG.warn("Error querying collection {}", collectionID, x); diff --git a/src/main/java/smithereen/activitypub/ActivityPubWorker.java b/src/main/java/smithereen/activitypub/ActivityPubWorker.java index ae6ab1fa..80bd8e7c 100644 --- a/src/main/java/smithereen/activitypub/ActivityPubWorker.java +++ b/src/main/java/smithereen/activitypub/ActivityPubWorker.java @@ -70,7 +70,7 @@ import smithereen.model.PollVote; import smithereen.model.Post; import smithereen.model.PrivacySetting; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.model.UserPrivacySettingKey; import smithereen.model.notifications.NotificationUtils; @@ -190,7 +190,7 @@ private Set getInboxesForPost(Post post) throws SQLException{ private void sendActivityForPost(Post post, Activity activity, Actor actor){ try{ Set inboxes=getInboxesForPost(post); - LOG.info("Inboxes: {}", inboxes); + LOG.trace("Inboxes: {}", inboxes); for(URI inbox:inboxes){ executor.submit(new SendOneActivityRunnable(activity, inbox, actor)); } @@ -523,7 +523,7 @@ public void sendLikeActivity(Post post, User user, int likeID) throws SQLExcepti like.actor=new LinkOrObject(user.activityPubID); like.object=new LinkOrObject(post.getActivityPubID()); Set inboxes=PostStorage.getInboxesForPostInteractionForwarding(post); - LOG.info("Inboxes: {}", inboxes); + LOG.trace("Inboxes: {}", inboxes); for(URI inbox:inboxes){ executor.submit(new SendOneActivityRunnable(like, inbox, user)); } @@ -783,6 +783,16 @@ public void sendReadMessageActivity(User self, MailMessage msg){ } } + public void sendUserDeleteSelf(User self) throws SQLException{ + Delete del=new Delete(); + del.activityPubID=new UriBuilder(self.activityPubID).fragment("deleteSelf").build(); + del.actor=new LinkOrObject(self.activityPubID); + del.object=new LinkOrObject(self.activityPubID); + for(URI inbox:UserStorage.getFollowerInboxes(self.id)){ + executor.submit(new SendOneActivityRunnable(del, inbox, self)); + } + } + public synchronized Future> fetchReplyThread(NoteOrQuestion post){ return fetchingReplyThreads.computeIfAbsent(post.activityPubID, (uri)->executor.submit(new FetchReplyThreadRunnable(post))); } @@ -802,7 +812,7 @@ public synchronized Future fetchAllReplies(Post post){ * @param actor the remote actor */ public synchronized void fetchActorRelationshipCollections(Actor actor){ - LOG.info("Fetching relationship collections for actor {}", actor.activityPubID); + LOG.debug("Fetching relationship collections for actor {}", actor.activityPubID); actor.ensureRemote(); if(fetchingRelationshipCollectionsActors.contains(actor.activityPubID)){ LOG.trace("Another fetch is already in progress for {}", actor.activityPubID); @@ -818,7 +828,7 @@ public synchronized void fetchActorRelationshipCollections(Actor actor){ * @param actor the remote actor */ public synchronized void fetchActorContentCollections(Actor actor){ - LOG.info("Fetching content collections for actor {}", actor.activityPubID); + LOG.debug("Fetching content collections for actor {}", actor.activityPubID); actor.ensureRemote(); if(fetchingContentCollectionsActors.contains(actor.activityPubID)){ LOG.trace("Another fetch is already in progress for {}", actor.activityPubID); @@ -951,7 +961,7 @@ public List call() throws Exception{ realThread.add(p); parent=p; } - LOG.info("Done fetching parent thread for post {}", topLevel.activityPubID); + LOG.debug("Done fetching parent thread for post {}", topLevel.activityPubID); synchronized(ActivityPubWorker.this){ fetchingReplyThreads.remove(initialPost.activityPubID); List>> actions=afterFetchReplyThreadActions.remove(initialPost.activityPubID); @@ -1163,7 +1173,7 @@ protected void compute(){ synchronized(ActivityPubWorker.this){ fetchingRelationshipCollectionsActors.remove(actor.activityPubID); } - LOG.info("Done fetching relationship collections for {}", actor.activityPubID); + LOG.debug("Done fetching relationship collections for {}", actor.activityPubID); } } @@ -1188,7 +1198,7 @@ protected void compute(){ synchronized(ActivityPubWorker.this){ fetchingContentCollectionsActors.remove(actor.activityPubID); } - LOG.info("Done fetching content collections for {}", actor.activityPubID); + LOG.debug("Done fetching content collections for {}", actor.activityPubID); } } @@ -1551,7 +1561,7 @@ private RetryActivityRunnable(ActivityDeliveryRetry retry){ @Override public void run(){ - LOG.info("Retrying activity delivery to {}, attempt {}", retry.inbox, retry.attemptNumber); + LOG.debug("Retrying activity delivery to {}, attempt {}", retry.inbox, retry.attemptNumber); executor.submit(new SendOneActivityRunnable(retry.activity, retry.inbox, retry.actor, retry.attemptNumber)); } } diff --git a/src/main/java/smithereen/activitypub/handlers/CreateNoteHandler.java b/src/main/java/smithereen/activitypub/handlers/CreateNoteHandler.java index 98721094..d1325e76 100644 --- a/src/main/java/smithereen/activitypub/handlers/CreateNoteHandler.java +++ b/src/main/java/smithereen/activitypub/handlers/CreateNoteHandler.java @@ -38,7 +38,7 @@ public class CreateNoteHandler extends ActivityTypeHandler content=new ArrayList<>(); List objects=activity.object.stream().map(uri->context.appContext.getObjectLinkResolver().resolveNative(uri, Object.class, false, false, false, (JsonObject) null, true)).toList(); for(Object obj:objects){ if(obj instanceof Actor a){ if(reportedActor==null) reportedActor=a; - }else if(reportedContent==null){ - reportedContent=obj; + }else if(obj instanceof ReportableContentObject rco){ + content.add(rco); } } if(reportedActor==null) throw new BadRequestException("None of the URIs in Flag.object point to an Actor"); - context.appContext.getModerationController().createViolationReport(reporter, reportedActor, reportedContent, activity.content, actor.domain); + context.appContext.getModerationController().createViolationReport(reporter, reportedActor, content, activity.content, actor.domain); } } diff --git a/src/main/java/smithereen/activitypub/objects/ActivityPubObject.java b/src/main/java/smithereen/activitypub/objects/ActivityPubObject.java index 9eb7f5db..4a3232d9 100644 --- a/src/main/java/smithereen/activitypub/objects/ActivityPubObject.java +++ b/src/main/java/smithereen/activitypub/objects/ActivityPubObject.java @@ -21,6 +21,7 @@ import java.util.regex.Pattern; import smithereen.ApplicationContext; +import smithereen.Config; import smithereen.Utils; import smithereen.activitypub.SerializerContext; import smithereen.activitypub.ParserContext; @@ -44,12 +45,12 @@ import smithereen.activitypub.objects.activities.Update; import smithereen.model.ForeignGroup; import smithereen.model.ForeignUser; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.util.JsonArrayBuilder; import spark.utils.StringUtils; public abstract class ActivityPubObject{ - private static final Logger LOG=LoggerFactory.getLogger(ActivityPubObject.class); + protected static final Logger LOG=LoggerFactory.getLogger(ActivityPubObject.class); /*attachment | attributedTo | audience | content | context | name | endTime | generator | icon | image | inReplyTo | location | preview | published | replies | startTime | summary | tag | updated | url | to | bto | cc | bcc | mediaType | duration*/ @@ -199,15 +200,20 @@ protected static URI tryParseURL(String url){ if(url==null || url.isEmpty()) return null; try{ - URI uri=new URI(url); - if("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) || "as".equals(uri.getScheme())) + URI uri=UriBuilder.parseAndEncode(url); + if("https".equals(uri.getScheme()) || "as".equals(uri.getScheme())){ return uri; + }else if("http".equals(uri.getScheme())){ + if(Config.useHTTP) + return uri; + return new UriBuilder(uri).scheme("https").build(); + } if("bear".equals(uri.getScheme())){ Map params=UriBuilder.parseQueryString(uri.getRawQuery()); String token=params.get("t"); String _url=params.get("u"); if(StringUtils.isNotEmpty(token) && StringUtils.isNotEmpty(_url)){ - URI actualURL=new URI(_url); + URI actualURL=UriBuilder.parseAndEncode(_url); if("https".equals(actualURL.getScheme()) || "http".equals(actualURL.getScheme())) return uri; } diff --git a/src/main/java/smithereen/activitypub/objects/Actor.java b/src/main/java/smithereen/activitypub/objects/Actor.java index 4d789536..dad7415e 100644 --- a/src/main/java/smithereen/activitypub/objects/Actor.java +++ b/src/main/java/smithereen/activitypub/objects/Actor.java @@ -20,6 +20,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.time.Instant; import java.util.Base64; import java.util.Collections; @@ -46,7 +47,7 @@ public abstract class Actor extends ActivityPubObject{ public URI followers; public URI following; public URI collectionQueryEndpoint; - public Timestamp lastUpdated; + public Instant lastUpdated; public String aboutSource; @@ -179,8 +180,8 @@ protected ActivityPubObject parseActivityPubObject(JsonObject obj, ParserContext JsonObject pkey=obj.getAsJsonObject("publicKey"); if(pkey==null) throw new IllegalArgumentException("The actor is missing a public key (or @context in the actor object doesn't include the namespace \""+JLD.W3_SECURITY+"\")"); - URI keyOwner=tryParseURL(pkey.get("owner").getAsString()); - if(!keyOwner.equals(activityPubID)) + URI keyOwner=tryParseURL(optString(pkey, "owner")); + if(keyOwner!=null && !keyOwner.equals(activityPubID)) throw new IllegalArgumentException("Key owner ("+keyOwner+") is not equal to user ID ("+activityPubID+")"); String pkeyEncoded=pkey.get("publicKeyPem").getAsString(); pkeyEncoded=pkeyEncoded.replaceAll("-----(BEGIN|END) (RSA )?PUBLIC KEY-----", "").replaceAll("[^A-Za-z0-9+/=]", "").trim(); @@ -340,7 +341,7 @@ public String serializeEndpoints(){ } public int getOwnerID(){ - return 0; + throw new UnsupportedOperationException(); } public static class EndpointsStorageWrapper{ diff --git a/src/main/java/smithereen/activitypub/objects/Document.java b/src/main/java/smithereen/activitypub/objects/Document.java index cfe4bd4d..23dc09d4 100644 --- a/src/main/java/smithereen/activitypub/objects/Document.java +++ b/src/main/java/smithereen/activitypub/objects/Document.java @@ -9,7 +9,6 @@ public class Document extends ActivityPubObject{ - public String localID; public String blurHash; public int width; public int height; diff --git a/src/main/java/smithereen/activitypub/objects/LocalImage.java b/src/main/java/smithereen/activitypub/objects/LocalImage.java index 0c5a38e3..7293f2ac 100644 --- a/src/main/java/smithereen/activitypub/objects/LocalImage.java +++ b/src/main/java/smithereen/activitypub/objects/LocalImage.java @@ -1,37 +1,38 @@ package smithereen.activitypub.objects; -import com.google.gson.JsonArray; import com.google.gson.JsonObject; import java.net.URI; -import java.util.Objects; -import smithereen.Config; -import smithereen.activitypub.SerializerContext; import smithereen.activitypub.ParserContext; +import smithereen.activitypub.SerializerContext; import smithereen.model.SizedImage; +import smithereen.model.media.MediaFileRecord; +import smithereen.model.media.MediaFileType; import smithereen.storage.ImgProxy; +import smithereen.storage.media.MediaFileStorageDriver; public class LocalImage extends Image implements SizedImage{ - public String path; public Dimensions size=Dimensions.UNKNOWN; + public long fileID; + public MediaFileRecord fileRecord; @Override protected ActivityPubObject parseActivityPubObject(JsonObject obj, ParserContext parserContext){ super.parseActivityPubObject(obj, parserContext); - localID=obj.get("_lid").getAsString(); - JsonArray s=obj.getAsJsonArray("_sz"); - path=Objects.requireNonNullElse(optString(obj, "_p"), "post_media"); - width=s.get(0).getAsInt(); - height=s.get(1).getAsInt(); - size=new Dimensions(width, height); + if(obj.has("_fileID")) + fileID=obj.get("_fileID").getAsLong(); return this; } @Override public JsonObject asActivityPubObject(JsonObject obj, SerializerContext serializerContext){ obj=super.asActivityPubObject(obj, serializerContext); - ImgProxy.UrlBuilder builder=new ImgProxy.UrlBuilder("local://"+Config.imgproxyLocalUploads+"/"+path+"/"+localID+".webp") + if(fileRecord==null){ + LOG.warn("Tried to serialize a LocalImage with fileRecord not set (file ID {})", fileID); + return obj; + } + ImgProxy.UrlBuilder builder=MediaFileStorageDriver.getInstance().getImgProxyURL(fileRecord.id()) .format(isGraffiti ? SizedImage.Format.PNG : SizedImage.Format.JPEG); int croppedWidth=width, croppedHeight=height; if(cropRegion!=null){ @@ -46,7 +47,7 @@ public JsonObject asActivityPubObject(JsonObject obj, SerializerContext serializ Image im=new Image(); im.width=width; im.height=height; - im.url=new ImgProxy.UrlBuilder("local://"+Config.imgproxyLocalUploads+"/"+path+"/"+localID+".webp") + im.url=MediaFileStorageDriver.getInstance().getImgProxyURL(fileRecord.id()) .format(SizedImage.Format.JPEG) .build(); im.mediaType="image/jpeg"; @@ -59,7 +60,11 @@ public JsonObject asActivityPubObject(JsonObject obj, SerializerContext serializ @Override public URI getUriForSizeAndFormat(Type size, Format format){ - ImgProxy.UrlBuilder builder=new ImgProxy.UrlBuilder("local://"+Config.imgproxyLocalUploads+"/"+path+"/"+localID+".webp") + if(fileRecord==null){ + LOG.warn("Tried to get a URL for a LocalImage with fileRecord not set (file ID {})", fileID); + return null; + } + ImgProxy.UrlBuilder builder=MediaFileStorageDriver.getInstance().getImgProxyURL(fileRecord.id()) .format(format) .resize(size.getResizingType(), size.getMaxWidth(), size.getMaxHeight(), false, false); if(cropRegion!=null && size.getResizingType()==ImgProxy.ResizingType.FILL){ @@ -74,4 +79,22 @@ public URI getUriForSizeAndFormat(Type size, Format format){ public Dimensions getOriginalDimensions(){ return size; } + + public void fillIn(MediaFileRecord mfr){ + fileRecord=mfr; + width=mfr.metadata().width(); + height=mfr.metadata().height(); + size=new Dimensions(width, height); + cropRegion=mfr.metadata().cropRegion(); + blurHash=mfr.metadata().blurhash(); + isGraffiti=mfr.id().type()==MediaFileType.IMAGE_GRAFFITI; + } + + public String getLocalID(){ + if(fileRecord==null){ + LOG.warn("Tried to get a local ID for a LocalImage with fileRecord not set (file ID {})", fileID); + return null; + } + return fileRecord.id().getIDForClient(); + } } diff --git a/src/main/java/smithereen/activitypub/objects/NoteOrQuestion.java b/src/main/java/smithereen/activitypub/objects/NoteOrQuestion.java index 2cb4365e..51c73396 100644 --- a/src/main/java/smithereen/activitypub/objects/NoteOrQuestion.java +++ b/src/main/java/smithereen/activitypub/objects/NoteOrQuestion.java @@ -27,7 +27,7 @@ import smithereen.exceptions.BadRequestException; import smithereen.model.MailMessage; import smithereen.model.Post; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.exceptions.FederationException; import smithereen.exceptions.ObjectNotFoundException; @@ -66,7 +66,7 @@ public Post asNativePost(ApplicationContext context){ } // fix for Lemmy (and possibly something else) - boolean hasBogusURL=url!=null && !url.getHost().equalsIgnoreCase(activityPubID.getHost()); + boolean hasBogusURL=url!=null && !url.getHost().equalsIgnoreCase(activityPubID.getHost()) && !url.getHost().equalsIgnoreCase("www."+activityPubID.getHost()); String text=content; if(hasBogusURL) @@ -75,8 +75,11 @@ public Post asNativePost(ApplicationContext context){ post.text=text; post.createdAt=published!=null ? published : Instant.now(); post.updatedAt=updated; - if(sensitive!=null && sensitive && StringUtils.isNotEmpty(summary)){ - post.contentWarning=summary; + if(sensitive!=null && sensitive){ + if(StringUtils.isNotEmpty(summary)) + post.contentWarning=summary; + else + post.contentWarning=""; // Will be rendered as a translatable default string } post.setActivityPubID(activityPubID); @@ -84,7 +87,10 @@ public Post asNativePost(ApplicationContext context){ post.activityPubReplies=replies!=null ? replies.getObjectID() : null; if(post.activityPubReplies!=null) ensureHostMatchesID(post.activityPubReplies, "replies"); - post.attachments=attachment; + if(attachment!=null && attachment.size()>10) + post.attachments=attachment.subList(0, 10); + else + post.attachments=attachment; HashSet mentionedUserIDs=new HashSet<>(); if(tag!=null){ @@ -168,8 +174,8 @@ public static NoteOrQuestion fromNativePost(Post post, ApplicationContext contex Set to=new HashSet<>(), cc=new HashSet<>(); to.add(ActivityPub.AS_PUBLIC); - - noq.activityPubID=noq.url=post.getActivityPubID(); + noq.activityPubID=post.getActivityPubID(); + noq.url=post.activityPubURL==null ? noq.activityPubID : post.activityPubURL; if(post.activityPubReplies!=null){ noq.replies=new LinkOrObject(post.activityPubReplies); }else if(post.isLocal()){ @@ -190,6 +196,9 @@ public static NoteOrQuestion fromNativePost(Post post, ApplicationContext contex User author=context.getUsersController().getUserOrThrow(post.authorID); noq.content=post.text; + if(post.poll!=null && StringUtils.isNotEmpty(post.poll.question)){ + noq.content+="

"+Utils.escapeHTML(post.poll.question)+"

"; + } noq.attributedTo=author.activityPubID; noq.published=post.createdAt; noq.updated=post.updatedAt; diff --git a/src/main/java/smithereen/activitypub/objects/activities/Flag.java b/src/main/java/smithereen/activitypub/objects/activities/Flag.java index 9b701819..b937c88d 100644 --- a/src/main/java/smithereen/activitypub/objects/activities/Flag.java +++ b/src/main/java/smithereen/activitypub/objects/activities/Flag.java @@ -1,5 +1,6 @@ package smithereen.activitypub.objects.activities; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.net.URI; @@ -11,6 +12,7 @@ import smithereen.activitypub.ParserContext; import smithereen.activitypub.objects.Activity; import smithereen.activitypub.objects.ActivityPubObject; +import smithereen.exceptions.FederationException; import smithereen.util.JsonArrayBuilder; public class Flag extends Activity{ @@ -25,7 +27,11 @@ public JsonObject asActivityPubObject(JsonObject obj, SerializerContext serializ @Override protected ActivityPubObject parseActivityPubObject(JsonObject obj, ParserContext parserContext){ - object=StreamSupport.stream(Objects.requireNonNull(obj.getAsJsonArray("object")).spliterator(), false).map(el->tryParseURL(el.getAsString())).toList(); + JsonElement reportObject=obj.get("object"); + if(reportObject.isJsonArray()) + object=StreamSupport.stream(Objects.requireNonNull(reportObject.getAsJsonArray()).spliterator(), false).map(el->tryParseURL(el.getAsString())).toList(); + else if(reportObject.isJsonPrimitive()) + object=List.of(tryParseURL(reportObject.getAsString())); return super.parseActivityPubObject(obj, parserContext); } diff --git a/src/main/java/smithereen/controllers/GroupsController.java b/src/main/java/smithereen/controllers/GroupsController.java index dfd6746e..e64dcc34 100644 --- a/src/main/java/smithereen/controllers/GroupsController.java +++ b/src/main/java/smithereen/controllers/GroupsController.java @@ -490,7 +490,7 @@ public void declineInvitation(@NotNull User self, @NotNull Group group){ try{ URI apID=GroupStorage.getInvitationApID(self.id, group.id); int localID=GroupStorage.deleteInvitation(self.id, group.id, group.isEvent()); - if(localID>0 && group instanceof ForeignGroup fg){ + if(localID>0 && group instanceof ForeignGroup fg && apID!=null){ context.getActivityPubWorker().sendRejectGroupInvite(self, fg, localID, apID); } }catch(SQLException x){ diff --git a/src/main/java/smithereen/controllers/MailController.java b/src/main/java/smithereen/controllers/MailController.java index f3622636..15e85600 100644 --- a/src/main/java/smithereen/controllers/MailController.java +++ b/src/main/java/smithereen/controllers/MailController.java @@ -9,6 +9,8 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -24,9 +26,11 @@ import smithereen.Utils; import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.Actor; +import smithereen.activitypub.objects.LocalImage; import smithereen.model.ForeignUser; import smithereen.model.MailMessage; import smithereen.model.MessagesPrivacyGrant; +import smithereen.model.ObfuscatedObjectIDType; import smithereen.model.PaginatedList; import smithereen.model.Post; import smithereen.model.User; @@ -36,11 +40,15 @@ import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.exceptions.UserActionNotAllowedException; +import smithereen.model.media.MediaFileRecord; +import smithereen.model.media.MediaFileReferenceType; import smithereen.storage.MailStorage; import smithereen.storage.MediaCache; +import smithereen.storage.MediaStorage; import smithereen.storage.MediaStorageUtils; import smithereen.storage.NotificationsStorage; import smithereen.util.BackgroundTaskRunner; +import smithereen.util.XTEA; import spark.utils.StringUtils; public class MailController{ @@ -90,21 +98,10 @@ public long sendMessage(User self, int selfAccountID, Set _to, String text text=Utils.preprocessPostHTML(text, null); } int maxAttachments=10; - int attachmentCount=0; String attachments=null; + ArrayList attachObjects=new ArrayList<>(); if(!attachmentIDs.isEmpty()){ - ArrayList attachObjects=new ArrayList<>(); - for(String id:attachmentIDs){ - if(!id.matches("^[a-fA-F0-9]{32}$")) - continue; - ActivityPubObject obj=MediaCache.getAndDeleteDraftAttachment(id, selfAccountID, "mail_images"); - if(obj!=null){ - attachObjects.add(obj); - attachmentCount++; - } - if(attachmentCount==maxAttachments) - break; - } + MediaStorageUtils.fillAttachmentObjects(attachObjects, attachmentIDs, 0, maxAttachments); if(!attachObjects.isEmpty()){ if(attachObjects.size()==1){ attachments=MediaStorageUtils.serializeAttachment(attachObjects.get(0)).toString(); @@ -142,7 +139,15 @@ public long sendMessage(User self, int selfAccountID, Set _to, String text if(un!=null) un.incUnreadMailCount(1); } - long id=MailStorage.createMessage(text, Objects.requireNonNullElse(subject, ""), attachments, self.id, to.stream().map(u->u.id).collect(Collectors.toSet()), null, localOwners, null, replyInfos); + HashMap allMessageIDs=new HashMap<>(); + long id=MailStorage.createMessage(text, Objects.requireNonNullElse(subject, ""), attachments, self.id, to.stream().map(u->u.id).collect(Collectors.toSet()), null, localOwners, null, replyInfos, allMessageIDs); + for(ActivityPubObject att:attachObjects){ + if(att instanceof LocalImage li){ + for(Map.Entry mid:allMessageIDs.entrySet()){ + MediaStorage.createMediaFileReference(li.fileID, mid.getValue(), MediaFileReferenceType.MAIL_ATTACHMENT, mid.getKey()); + } + } + } for(User user:to){ MessagesPrivacyGrant grant=MailStorage.getPrivacyGrant(user.id, self.id); if(grant!=null && grant.isValid()){ @@ -220,6 +225,9 @@ public void actuallyDeleteMessage(User self, MailMessage message, boolean delete throw new IllegalArgumentException("This user can't delete this message"); } MailStorage.actuallyDeleteMessage(message.id); + if(message.attachments!=null && !message.attachments.isEmpty()){ + MediaStorage.deleteMediaFileReferences(XTEA.deobfuscateObjectID(message.id, ObfuscatedObjectIDType.MAIL_MESSAGE), MediaFileReferenceType.MAIL_ATTACHMENT); + } if(message.ownerID!=self.id){ UserNotifications un=NotificationsStorage.getNotificationsFromCache(message.ownerID); if(un!=null) @@ -229,15 +237,15 @@ public void actuallyDeleteMessage(User self, MailMessage message, boolean delete for(MailMessage msg:MailStorage.getMessages(message.relatedMessageIDs)){ if(msg.isUnread()){ MailStorage.actuallyDeleteMessage(msg.id); + if(message.attachments!=null && !message.attachments.isEmpty()){ + MediaStorage.deleteMediaFileReferences(XTEA.deobfuscateObjectID(msg.id, ObfuscatedObjectIDType.MAIL_MESSAGE), MediaFileReferenceType.MAIL_ATTACHMENT); + } UserNotifications un=NotificationsStorage.getNotificationsFromCache(msg.ownerID); if(un!=null) un.incUnreadMailCount(-1); } } } - if(message.attachments!=null && !message.attachments.isEmpty() && MailStorage.getMessageRefCount(message.relatedMessageIDs)==0){ - MediaStorageUtils.deleteAttachmentFiles(message.attachments); - } if(!(self instanceof ForeignUser) && deleteRelated && message.senderID==self.id){ context.getActivityPubWorker().sendDeleteMessageActivity(self, message); } @@ -266,8 +274,8 @@ public static void deleteRestorableMessages(){ return; MailStorage.actuallyDeleteMessages(messages.stream().map(m->m.id).collect(Collectors.toSet())); for(MailMessage message:messages){ - if(message.attachments!=null && !message.attachments.isEmpty() && MailStorage.getMessageRefCount(message.relatedMessageIDs)==0){ - MediaStorageUtils.deleteAttachmentFiles(message.attachments); + if(message.attachments!=null && !message.attachments.isEmpty()){ + MediaStorage.deleteMediaFileReferences(XTEA.deobfuscateObjectID(message.id, ObfuscatedObjectIDType.MAIL_MESSAGE), MediaFileReferenceType.MAIL_ATTACHMENT); } } }catch(SQLException x){ @@ -330,7 +338,7 @@ public void putForeignMessage(MailMessage msg){ .collect(Collectors.toSet()); if(localOwners.isEmpty()) throw new UserActionNotAllowedException(); - MailStorage.createMessage(msg.text, msg.subject!=null ? msg.subject : "", msg.getSerializedAttachments(), msg.senderID, msg.to, msg.cc, localOwners, msg.activityPubID, replyInfos); + MailStorage.createMessage(msg.text, msg.subject!=null ? msg.subject : "", msg.getSerializedAttachments(), msg.senderID, msg.to, msg.cc, localOwners, msg.activityPubID, replyInfos, null); for(int id:localOwners){ MessagesPrivacyGrant grant=MailStorage.getPrivacyGrant(id, msg.senderID); if(grant!=null && grant.isValid()) diff --git a/src/main/java/smithereen/controllers/ModerationController.java b/src/main/java/smithereen/controllers/ModerationController.java index 21456248..86b74201 100644 --- a/src/main/java/smithereen/controllers/ModerationController.java +++ b/src/main/java/smithereen/controllers/ModerationController.java @@ -1,49 +1,104 @@ package smithereen.controllers; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.InetAddress; import java.net.URI; import java.sql.SQLException; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import smithereen.ApplicationContext; +import smithereen.Config; import smithereen.LruCache; +import smithereen.Mailer; +import smithereen.SmithereenApplication; import smithereen.Utils; +import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.Actor; +import smithereen.activitypub.objects.LocalImage; +import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.InternalServerErrorException; +import smithereen.exceptions.ObjectNotFoundException; +import smithereen.exceptions.UserActionNotAllowedException; +import smithereen.exceptions.UserErrorException; +import smithereen.model.Account; import smithereen.model.ActivityPubRepresentable; +import smithereen.model.ActorStaffNote; import smithereen.model.AdminNotifications; +import smithereen.model.AuditLogEntry; +import smithereen.model.EmailDomainBlockRule; +import smithereen.model.EmailDomainBlockRuleFull; import smithereen.model.FederationRestriction; import smithereen.model.ForeignGroup; import smithereen.model.ForeignUser; -import smithereen.model.Group; +import smithereen.model.IPBlockRule; +import smithereen.model.IPBlockRuleFull; import smithereen.model.MailMessage; import smithereen.model.ObfuscatedObjectIDType; +import smithereen.model.OtherSession; import smithereen.model.PaginatedList; import smithereen.model.Post; +import smithereen.model.ReportableContentObject; import smithereen.model.Server; +import smithereen.model.SessionInfo; +import smithereen.model.SignupInvitation; import smithereen.model.User; +import smithereen.model.UserBanInfo; +import smithereen.model.UserBanStatus; +import smithereen.model.UserPermissions; +import smithereen.model.UserRole; import smithereen.model.ViolationReport; -import smithereen.exceptions.InternalServerErrorException; -import smithereen.exceptions.ObjectNotFoundException; +import smithereen.model.ViolationReportAction; +import smithereen.model.media.MediaFileRecord; +import smithereen.model.media.MediaFileReferenceType; +import smithereen.model.viewmodel.AdminUserViewModel; +import smithereen.model.viewmodel.UserRoleViewModel; +import smithereen.storage.MediaStorage; import smithereen.storage.ModerationStorage; +import smithereen.storage.SessionStorage; +import smithereen.storage.UserStorage; +import smithereen.util.InetAddressRange; +import smithereen.util.JsonArrayBuilder; import smithereen.util.XTEA; +import spark.Request; +import spark.utils.StringUtils; public class ModerationController{ private static final Logger LOG=LoggerFactory.getLogger(ModerationController.class); private final ApplicationContext context; private final LruCache serversByDomainCache=new LruCache<>(500); + private final Object emailRulesLock=new Object(); + private final Object ipRulesLock=new Object(); + private List emailDomainRules; + private List ipRules; public ModerationController(ApplicationContext context){ this.context=context; } - public void createViolationReport(User self, Actor target, @Nullable Object content, String comment, boolean forward){ + public void createViolationReport(User self, Actor target, @Nullable List content, String comment, boolean forward){ int reportID=createViolationReportInternal(self, target, content, comment, null); if(forward && (target instanceof ForeignGroup || target instanceof ForeignUser)){ ArrayList objectIDs=new ArrayList<>(); @@ -54,41 +109,34 @@ public void createViolationReport(User self, Actor target, @Nullable Object cont } } - public void createViolationReport(@Nullable User self, Actor target, @Nullable Object content, String comment, String otherServerDomain){ + public void createViolationReport(@Nullable User self, Actor target, @Nullable List content, String comment, String otherServerDomain){ createViolationReportInternal(self, target, content, comment, otherServerDomain); } - private int createViolationReportInternal(@Nullable User self, Actor target, @Nullable Object content, String comment, String otherServerDomain){ + private int createViolationReportInternal(@Nullable User self, Actor target, @Nullable List content, String comment, String otherServerDomain){ try{ - ViolationReport.TargetType targetType; - ViolationReport.ContentType contentType; - int targetID; - long contentID; - if(target instanceof User u){ - targetType=ViolationReport.TargetType.USER; - targetID=u.id; - }else if(target instanceof Group g){ - targetType=ViolationReport.TargetType.GROUP; - targetID=g.id; - }else{ - throw new IllegalArgumentException(); - } + int targetID=target.getOwnerID(); - if(content instanceof Post p){ - contentType=ViolationReport.ContentType.POST; - contentID=p.id; - }else if(content instanceof MailMessage msg){ - contentType=ViolationReport.ContentType.MESSAGE; - contentID=XTEA.deobfuscateObjectID(msg.id, ObfuscatedObjectIDType.MAIL_MESSAGE); + HashSet contentFileIDs=new HashSet<>(); + JsonArray contentJson; + if(content!=null && !content.isEmpty()){ + JsonArrayBuilder ab=new JsonArrayBuilder(); + for(ReportableContentObject obj: content){ + JsonObject jo=obj.serializeForReport(targetID, contentFileIDs); + if(jo!=null) + ab.add(jo); + } + contentJson=ab.build(); }else{ - contentType=null; - contentID=0; + contentJson=null; } - int id=ModerationStorage.createViolationReport(self!=null ? self.id : 0, targetType, targetID, contentType, contentID, comment, otherServerDomain); - AdminNotifications an=AdminNotifications.getInstance(null); - if(an!=null){ - an.openReportsCount=getViolationReportsCount(true); + int id=ModerationStorage.createViolationReport(self!=null ? self.id : 0, targetID, comment, otherServerDomain, contentJson==null ? null : contentJson.toString()); + updateReportsCounter(); + for(long fid: contentFileIDs){ + // ownerID set to 0 because reports aren't owned by any particular actor + // and referenced files should stick around regardless of any account deletions + MediaStorage.createMediaFileReference(fid, id, MediaFileReferenceType.REPORT_OBJECT, 0); } return id; }catch(SQLException x){ @@ -112,24 +160,53 @@ public PaginatedList getViolationReports(boolean open, int offs } } - public ViolationReport getViolationReportByID(int id){ + public PaginatedList getViolationReportsOfActor(Actor actor, int offset, int count){ try{ - ViolationReport report=ModerationStorage.getViolationReportByID(id); - if(report==null) - throw new ObjectNotFoundException(); - return report; + return ModerationStorage.getViolationReportsOfActor(actor.getLocalID(), offset, count); }catch(SQLException x){ throw new InternalServerErrorException(x); } } - public void setViolationReportResolved(ViolationReport report, User moderator){ + public PaginatedList getViolationReportsByUser(User user, int offset, int count){ try{ - ModerationStorage.setViolationReportResolved(report.id, moderator.id); - AdminNotifications an=AdminNotifications.getInstance(null); - if(an!=null){ - an.openReportsCount=getViolationReportsCount(true); + return ModerationStorage.getViolationReportsByUser(user.id, offset, count); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public ViolationReport getViolationReportByID(int id, boolean needFiles){ + try{ + ViolationReport report=ModerationStorage.getViolationReportByID(id); + if(report==null) + throw new ObjectNotFoundException(); + if(needFiles && !report.content.isEmpty()){ + HashSet localImages=new HashSet<>(); + for(ReportableContentObject rco: report.content){ + List attachments=switch(rco){ + case Post p -> p.getAttachments(); + case MailMessage m -> m.getAttachments(); + }; + if(attachments==null) + continue; + for(ActivityPubObject att: attachments){ + if(att instanceof LocalImage li){ + localImages.add(li); + } + } + } + Set fileIDs=localImages.stream().map(li->li.fileID).collect(Collectors.toSet()); + if(!fileIDs.isEmpty()){ + Map files=MediaStorage.getMediaFileRecords(fileIDs); + for(LocalImage li: localImages){ + MediaFileRecord mfr=files.get(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } + } } + return report; }catch(SQLException x){ throw new InternalServerErrorException(x); } @@ -219,4 +296,594 @@ public void recordFederationFailure(Server server){ throw new InternalServerErrorException(x); } } + + public void setAccountRole(Account self, Account account, int roleID){ + UserRole ownRole=Config.userRoles.get(self.roleID); + UserRole targetRole=null; + if(roleID>0){ + targetRole=Config.userRoles.get(roleID); + if(targetRole==null) + throw new BadRequestException(); + } + // If not an owner and the user already has a role, can only change roles for someone you promoted yourself + if(account.roleID>0 && !ownRole.permissions().contains(UserRole.Permission.SUPERUSER)){ + if(account.promotedBy!=self.id) + throw new UserActionNotAllowedException(); + } + // Can only assign one's own role or a lesser one + if(targetRole!=null && !ownRole.permissions().contains(UserRole.Permission.SUPERUSER) && !ownRole.permissions().containsAll(targetRole.permissions())) + throw new UserActionNotAllowedException(); + try{ + UserStorage.setAccountRole(account, roleID, targetRole==null ? 0 : self.id); + ModerationStorage.createAuditLogEntry(self.user.id, AuditLogEntry.Action.ASSIGN_ROLE, account.user.id, roleID, AuditLogEntry.ObjectType.ROLE, null); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public List getRoles(UserPermissions ownPermissions){ + try{ + Map roleCounts=ModerationStorage.getRoleAccountCounts(); + boolean canEditAll=ownPermissions.hasPermission(UserRole.Permission.SUPERUSER); + return Config.userRoles.values() + .stream() + .sorted(Comparator.comparingInt(UserRole::id)) + .map(r->new UserRoleViewModel(r, roleCounts.getOrDefault(r.id(), 0), canEditAll || ownPermissions.role.permissions().containsAll(r.permissions()))) + .toList(); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void updateRole(User self, UserPermissions ownPermissions, UserRole role, String name, EnumSet permissions){ + try{ + // Can only use permissions they have themselves + if(!ownPermissions.hasPermission(UserRole.Permission.SUPERUSER) && !ownPermissions.role.permissions().containsAll(permissions)) + throw new UserActionNotAllowedException(); + // Can't make role #1 not be superuser role + if(role.id()==1 && !permissions.contains(UserRole.Permission.SUPERUSER)) + throw new UserActionNotAllowedException(); + // Can't change permissions on user's own role but can change settings and name + if(ownPermissions.role.id()==role.id()){ + EnumSet actualPermissions=EnumSet.copyOf(permissions); + actualPermissions.removeIf(UserRole.Permission::isActuallySetting); + if(!permissions.containsAll(actualPermissions)) + throw new UserActionNotAllowedException(); + } + if(permissions.isEmpty()) + throw new BadRequestException(); + // Nothing changed + if(role.name().equals(name) && role.permissions().equals(permissions)) + return; + ModerationStorage.updateRole(role.id(), name, permissions); + UserStorage.resetAccountsCache(); + SessionStorage.resetPermissionsCache(); + Config.reloadRoles(); + + HashMap extra=new HashMap<>(); + if(!role.name().equals(name)){ + extra.put("oldName", role.name()); + extra.put("newName", name); + } + if(!role.permissions().equals(permissions)){ + extra.put("oldPermissions", Base64.getEncoder().withoutPadding().encodeToString(Utils.serializeEnumSetToBytes(role.permissions()))); + extra.put("newPermissions", Base64.getEncoder().withoutPadding().encodeToString(Utils.serializeEnumSetToBytes(permissions))); + } + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.EDIT_ROLE, 0, role.id(), AuditLogEntry.ObjectType.ROLE, extra); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public UserRole createRole(User self, UserPermissions ownPermissions, String name, EnumSet permissions){ + try{ + // Can only use permissions they have themselves + if(!ownPermissions.hasPermission(UserRole.Permission.SUPERUSER) && !ownPermissions.role.permissions().containsAll(permissions)) + throw new UserActionNotAllowedException(); + if(permissions.isEmpty()) + throw new BadRequestException(); + int id=ModerationStorage.createRole(name, permissions); + Config.reloadRoles(); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.CREATE_ROLE, 0, id, AuditLogEntry.ObjectType.ROLE, Map.of( + "name", name, + "permissions", Base64.getEncoder().withoutPadding().encodeToString(Utils.serializeEnumSetToBytes(permissions)) + )); + return Config.userRoles.get(id); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void deleteRole(User self, UserPermissions ownPermissions, UserRole role){ + try{ + // Can only delete roles with same or lesser permissions as their own role + if(!ownPermissions.hasPermission(UserRole.Permission.SUPERUSER) && !ownPermissions.role.permissions().containsAll(role.permissions())) + throw new UserActionNotAllowedException(); + // Can't delete the superuser role + if(role.id()==1) + throw new UserActionNotAllowedException(); + // Can't delete their own role because that would be a stupid thing to do + if(role.id()==ownPermissions.role.id()) + throw new UserActionNotAllowedException(); + ModerationStorage.deleteRole(role.id()); + UserStorage.resetAccountsCache(); + SessionStorage.resetPermissionsCache(); + Config.reloadRoles(); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.DELETE_ROLE, 0, role.id(), AuditLogEntry.ObjectType.ROLE, Map.of( + "name", role.name(), + "permissions", Base64.getEncoder().withoutPadding().encodeToString(Utils.serializeEnumSetToBytes(role.permissions())) + )); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public PaginatedList getGlobalAuditLog(int offset, int count){ + try{ + return ModerationStorage.getGlobalAuditLog(offset, count); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public PaginatedList getUserAuditLog(User user, int offset, int count){ + try{ + return ModerationStorage.getUserAuditLog(user.id, offset, count); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public PaginatedList getAllUsers(int offset, int count, String query, Boolean localOnly, String emailDomain, String ipSubnet, int roleID){ + try{ + InetAddressRange subnet=ipSubnet!=null ? InetAddressRange.parse(ipSubnet) : null; + return ModerationStorage.getUsers(query, localOnly, emailDomain, subnet, roleID, offset, count); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public Map getAccounts(Collection ids){ + if(ids.isEmpty()) + return Map.of(); + try{ + return UserStorage.getAccounts(ids); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void setUserEmail(User self, Account account, String newEmail){ + try{ + String oldEmail=account.email; + SessionStorage.updateActivationInfo(account.id, null); + SessionStorage.updateEmail(account.id, newEmail); + account.email=newEmail; + account.activationInfo=null; + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.SET_USER_EMAIL, account.user.id, 0, null, Map.of("oldEmail", oldEmail, "newEmail", newEmail)); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void terminateUserSession(User self, Account account, OtherSession session){ + try{ + SessionStorage.deleteSession(account.id, session.fullID()); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.END_USER_SESSION, account.user.id, 0, null, Map.of("ip", Base64.getEncoder().withoutPadding().encodeToString(Utils.serializeInetAddress(session.ip())))); + SmithereenApplication.invalidateAllSessionsForAccount(account.id); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void setUserBanStatus(User self, User target, Account targetAccount, UserBanStatus status, UserBanInfo info){ + try{ + if(self.id==target.id && status!=UserBanStatus.NONE) + throw new UserErrorException("You can't ban yourself"); + UserStorage.setUserBanStatus(target, targetAccount, status, status!=UserBanStatus.NONE ? Utils.gson.toJson(info) : null); + HashMap auditLogArgs=new HashMap<>(); + auditLogArgs.put("status", status); + if(info!=null){ + if(info.expiresAt()!=null) + auditLogArgs.put("expiresAt", info.expiresAt().toEpochMilli()); + if(StringUtils.isNotEmpty(info.message())) + auditLogArgs.put("message", info.message()); + if(info.reportID()>0) + auditLogArgs.put("report", info.reportID()); + } + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.BAN_USER, target.id, 0, null, auditLogArgs); + if(!(target instanceof ForeignUser) && (status==UserBanStatus.FROZEN || status==UserBanStatus.SUSPENDED)){ + Account account=SessionStorage.getAccountByUserID(target.id); + Mailer.getInstance().sendAccountBanNotification(account, status, info); + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void clearUserBanStatus(Account self){ + try{ + UserStorage.setUserBanStatus(self.user, self, UserBanStatus.NONE, null); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + private void updateReportsCounter(){ + AdminNotifications an=AdminNotifications.getInstance(null); + if(an!=null){ + an.openReportsCount=getViolationReportsCount(true); + } + } + + public List getViolationReportActions(ViolationReport report){ + try{ + return ModerationStorage.getViolationReportActions(report.id); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void rejectViolationReport(ViolationReport report, User self){ + try{ + if(report.state!=ViolationReport.State.OPEN) + throw new IllegalArgumentException("Report is not open"); + ModerationStorage.setViolationReportState(report.id, ViolationReport.State.CLOSED_REJECTED); + ModerationStorage.createViolationReportAction(report.id, self.id, ViolationReportAction.ActionType.RESOLVE_REJECT, null, null); + updateReportsCounter(); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void markViolationReportUnresolved(ViolationReport report, User self){ + try{ + if(report.state==ViolationReport.State.OPEN) + throw new IllegalArgumentException("Report is already open"); + ModerationStorage.setViolationReportState(report.id, ViolationReport.State.OPEN); + ModerationStorage.createViolationReportAction(report.id, self.id, ViolationReportAction.ActionType.REOPEN, null, null); + updateReportsCounter(); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void addViolationReportComment(ViolationReport report, User self, String commentText){ + try{ + ModerationStorage.createViolationReportAction(report.id, self.id, ViolationReportAction.ActionType.COMMENT, Utils.preprocessPostHTML(commentText, null), null); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void resolveViolationReport(ViolationReport report, User self, UserBanStatus status, UserBanInfo banInfo){ + try{ + if(report.state!=ViolationReport.State.OPEN) + throw new IllegalArgumentException("Report is not open"); + ModerationStorage.setViolationReportState(report.id, ViolationReport.State.CLOSED_ACTION_TAKEN); + HashMap extra=new HashMap<>(); + extra.put("status", status); + if(banInfo!=null){ + if(banInfo.expiresAt()!=null) + extra.put("expiresAt", banInfo.expiresAt().toEpochMilli()); + if(StringUtils.isNotEmpty(banInfo.message())) + extra.put("message", banInfo.message()); + } + ModerationStorage.createViolationReportAction(report.id, self.id, ViolationReportAction.ActionType.RESOLVE_WITH_ACTION, null, Utils.gson.toJsonTree(extra).getAsJsonObject()); + updateReportsCounter(); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void deleteViolationReportContent(ViolationReport report, SessionInfo session, boolean markResolved){ + if(report.content==null) + return; + if(report.state!=ViolationReport.State.OPEN) + throw new IllegalArgumentException("Report is not open"); + try{ + boolean actuallyDeletedAnything=false; + for(ReportableContentObject rco: report.content){ + switch(rco){ + case Post post -> { + try{ + context.getWallController().getPostOrThrow(post.id); + }catch(ObjectNotFoundException x){ + LOG.debug("Post {} already deleted", post.id); + continue; + } + context.getWallController().deletePostAsServerModerator(session, post); + } + case MailMessage msg -> { + if(context.getMailController().getMessagesAsModerator(Set.of(XTEA.obfuscateObjectID(msg.id, ObfuscatedObjectIDType.MAIL_MESSAGE))).isEmpty()){ + LOG.debug("Message {} already deleted", msg.id); + continue; + } + User sender=context.getUsersController().getUserOrThrow(msg.senderID); + context.getMailController().actuallyDeleteMessage(sender, msg, true); + } + } + actuallyDeletedAnything=true; + } + if(actuallyDeletedAnything){ + MediaStorage.deleteMediaFileReferences(report.id, MediaFileReferenceType.REPORT_OBJECT); + ModerationStorage.createViolationReportAction(report.id, session.account.user.id, ViolationReportAction.ActionType.DELETE_CONTENT, null, null); + if(markResolved){ + ModerationStorage.setViolationReportState(report.id, ViolationReport.State.CLOSED_ACTION_TAKEN); + updateReportsCounter(); + } + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public int getUserStaffNoteCount(User user){ + try{ + return ModerationStorage.getUserStaffNoteCount(user.id); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public PaginatedList getUserStaffNotes(User user, int offset, int count){ + try{ + return ModerationStorage.getUserStaffNotes(user.id, offset, count); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void createUserStaffNote(User self, User target, String text){ + try{ + ModerationStorage.createUserStaffNote(target.id, self.id, Utils.preprocessPostHTML(text, null)); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void deleteUserStaffNote(ActorStaffNote note){ + try{ + ModerationStorage.deleteUserStaffNote(note.id()); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public ActorStaffNote getUserStaffNoteOrThrow(int id){ + try{ + ActorStaffNote note=ModerationStorage.getUserStaffNote(id); + if(note==null) + throw new ObjectNotFoundException(); + return note; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public List getEmailDomainBlockRules(){ + try{ + synchronized(emailRulesLock){ + if(emailDomainRules!=null) + return emailDomainRules; + emailDomainRules=Collections.unmodifiableList(ModerationStorage.getEmailDomainBlockRules()); + return emailDomainRules; + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + private void reloadEmailDomainBlockCache() throws SQLException{ + synchronized(emailRulesLock){ + emailDomainRules=Collections.unmodifiableList(ModerationStorage.getEmailDomainBlockRules()); + } + } + + private String normalizeDomain(String domain){ + return Utils.convertIdnToAsciiIfNeeded(domain).toLowerCase(); + } + + public void createEmailDomainBlockRule(User self, String domain, EmailDomainBlockRule.Action action, String note){ + try{ + domain=normalizeDomain(domain); + EmailDomainBlockRuleFull rule=ModerationStorage.getEmailDomainBlockRuleFull(domain); + if(rule!=null) + throw new UserErrorException("err_admin_email_rule_already_exists"); + ModerationStorage.createEmailDomainBlockRule(domain, action, note, self.id); + reloadEmailDomainBlockCache(); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.CREATE_EMAIL_DOMAIN_RULE, 0, 0, null, Map.of("domain", domain, "action", action.toString())); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void deleteEmailDomainBlockRule(User self, EmailDomainBlockRuleFull rule){ + try{ + ModerationStorage.deleteEmailDomainBlockRule(normalizeDomain(rule.rule().domain())); + reloadEmailDomainBlockCache(); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.DELETE_EMAIL_DOMAIN_RULE, 0, 0, null, Map.of("domain", rule.rule().domain(), "action", rule.rule().action().toString())); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void updateEmailDomainBlockRule(User self, EmailDomainBlockRuleFull rule, EmailDomainBlockRule.Action action, String note){ + try{ + if(action==rule.rule().action() && Objects.equals(rule.note(), note)) + return; + ModerationStorage.updateEmailDomainBlockRule(rule.rule().domain(), action, note); + if(action!=rule.rule().action()){ + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.UPDATE_EMAIL_DOMAIN_RULE, 0, 0, null, + Map.of("domain", rule.rule().domain(), "oldAction", rule.rule().action().toString(), "newAction", action.toString())); + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public EmailDomainBlockRule matchEmailDomainBlockRule(String email){ + if(!Utils.isValidEmail(email)) + throw new IllegalArgumentException("'"+email+"' is not a valid email"); + String domain=normalizeDomain(email.split("@", 2)[1]); + List rules=getEmailDomainBlockRules(); + for(EmailDomainBlockRule rule: rules){ + if(rule.matches(domain)) + return rule; + } + return null; + } + + public List getEmailDomainBlockRulesFull(){ + try{ + return ModerationStorage.getEmailDomainBlockRulesFull(); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public EmailDomainBlockRuleFull getEmailDomainBlockRuleOrThrow(String domain){ + domain=normalizeDomain(domain); + try{ + EmailDomainBlockRuleFull rule=ModerationStorage.getEmailDomainBlockRuleFull(domain); + if(rule==null) + throw new ObjectNotFoundException(); + return rule; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public List getIPBlockRules(){ + try{ + synchronized(ipRulesLock){ + if(ipRules!=null) + return ipRules; + ipRules=Collections.unmodifiableList(ModerationStorage.getIPBlockRules()); + return ipRules; + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + private void reloadIpBlockCache() throws SQLException{ + synchronized(ipRulesLock){ + ipRules=Collections.unmodifiableList(ModerationStorage.getIPBlockRules()); + } + } + + public void createIPBlockRule(User self, InetAddressRange addressRange, IPBlockRule.Action action, int expiryMinutes, String note){ + try{ + Instant expiry=Instant.now().plus(expiryMinutes, ChronoUnit.MINUTES); + ModerationStorage.createIPBlockRule(addressRange, action, expiry, note, self.id); + reloadIpBlockCache(); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.CREATE_IP_RULE, 0, 0, null, + Map.of("addr", addressRange.toString(), "expiry", expiry.getEpochSecond(), "action", action.toString())); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void deleteIPBlockRule(User self, IPBlockRuleFull rule){ + try{ + ModerationStorage.deleteIPBlockRule(rule.rule().id()); + reloadIpBlockCache(); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.DELETE_IP_RULE, 0, 0, null, + Map.of("addr", rule.rule().ipRange().toString(), "expiry", rule.rule().expiresAt().getEpochSecond(), "action", rule.rule().action().toString())); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void updateIPBlockRule(User self, IPBlockRuleFull rule, IPBlockRule.Action action, int newExpiryMinutes, String note){ + try{ + Instant newExpiry=newExpiryMinutes>0 ? Instant.now().plus(newExpiryMinutes, ChronoUnit.MINUTES) : rule.rule().expiresAt(); + HashMap auditLogArgs=new HashMap<>(); + if(newExpiryMinutes!=0){ + auditLogArgs.put("oldExpiry", rule.rule().expiresAt().getEpochSecond()); + auditLogArgs.put("newExpiry", newExpiry.getEpochSecond()); + } + if(action!=rule.rule().action()){ + auditLogArgs.put("oldRule", rule.rule().action().toString()); + auditLogArgs.put("newRule", rule.toString()); + } + ModerationStorage.updateIPBlockRule(rule.rule().id(), action, newExpiry, note); + reloadIpBlockCache(); + if(!auditLogArgs.isEmpty()){ + auditLogArgs.put("addr", rule.rule().ipRange().toString()); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.UPDATE_IP_RULE, 0, 0, null, auditLogArgs); + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public IPBlockRule matchIPBlockRule(InetAddress ip){ + List rules=getIPBlockRules(); + Instant now=Instant.now(); + for(IPBlockRule rule: rules){ + if(rule.ipRange().contains(ip) && rule.expiresAt().isAfter(now)) + return rule; + } + return null; + } + + public List getIPBlockRulesFull(){ + try{ + return ModerationStorage.getIPBlockRulesFull(); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public IPBlockRuleFull getIPBlockRuleFull(int id){ + try{ + IPBlockRuleFull rule=ModerationStorage.getIPBlockRuleFull(id); + if(rule==null) + throw new ObjectNotFoundException(); + return rule; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public Config.SignupMode getEffectiveSignupMode(Request req){ + InetAddress ip=Utils.getRequestIP(req); + IPBlockRule rule=matchIPBlockRule(ip); + if(rule!=null){ + if(rule.action()==IPBlockRule.Action.MANUAL_REVIEW_SIGNUPS && Config.signupMode==Config.SignupMode.OPEN){ + return Config.SignupMode.MANUAL_APPROVAL; + }else if(rule.action()==IPBlockRule.Action.BLOCK_SIGNUPS){ + return Config.SignupMode.CLOSED; + } + } + return Config.signupMode; + } + + public PaginatedList getAllSignupInvites(int offset, int count){ + try{ + return ModerationStorage.getAllSignupInvites(offset, count); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void deleteSignupInvite(User self, int id){ + try{ + SignupInvitation invite=context.getUsersController().getInvite(id); + if(invite==null) + throw new ObjectNotFoundException(); + SessionStorage.deleteInvitation(id); + Map auditLogArgs=new HashMap<>(); + auditLogArgs.put("signups", invite.signupsRemaining); + if(StringUtils.isNotEmpty(invite.email)) + auditLogArgs.put("email", invite.email); + if(StringUtils.isNotEmpty(invite.firstName)) + auditLogArgs.put("name", invite.firstName+" "+invite.lastName); + ModerationStorage.createAuditLogEntry(self.id, AuditLogEntry.Action.DELETE_SIGNUP_INVITE, invite.ownerID, invite.id, AuditLogEntry.ObjectType.SIGNUP_INVITE, auditLogArgs); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } } diff --git a/src/main/java/smithereen/controllers/ObjectLinkResolver.java b/src/main/java/smithereen/controllers/ObjectLinkResolver.java index 2a49388f..9a688fd9 100644 --- a/src/main/java/smithereen/controllers/ObjectLinkResolver.java +++ b/src/main/java/smithereen/controllers/ObjectLinkResolver.java @@ -33,11 +33,12 @@ import smithereen.model.Group; import smithereen.model.MailMessage; import smithereen.model.Post; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.exceptions.FederationException; import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; +import smithereen.model.UserBanStatus; import smithereen.storage.GroupStorage; import smithereen.storage.MailStorage; import smithereen.storage.PostStorage; @@ -166,7 +167,7 @@ public T resolveNative(URI _link, Class expectedType, boolean allowFetchi @NotNull public T resolveNative(URI _link, Class expectedType, boolean allowFetching, boolean allowStorage, boolean forceRefetch, JsonObject actorToken, boolean bypassCollectionCheck){ try{ - LOG.debug("Resolving ActivityPub link: {}, expected type: {}, allow storage {}", _link, expectedType.getName(), allowStorage); + LOG.debug("Resolving ActivityPub link: {}, expected type: {}, allow storage {}, force refetch {}", _link, expectedType.getName(), allowStorage, forceRefetch); URI link; if("bear".equals(_link.getScheme())){ link=URI.create(UriBuilder.parseQueryString(_link.getRawQuery()).get("u")); @@ -210,6 +211,12 @@ public T resolveNative(URI _link, Class expectedType, boolean allowFetchi handleNewlyFetchedMovedUser(fu); } } + if(obj instanceof NoteOrQuestion noq && !allowStorage && expectedType.isAssignableFrom(NoteOrQuestion.class)){ + User author=resolve(noq.attributedTo, User.class, allowFetching, true, false); + if(author.banStatus==UserBanStatus.SUSPENDED) + throw new ObjectNotFoundException("Post author is suspended on this server"); + return ensureTypeAndCast(obj, expectedType); + } T o=convertToNativeObject(obj, expectedType); if(!bypassCollectionCheck && o instanceof Post post && obj.inReplyTo==null){ // TODO make this a generalized interface OwnedObject or something if(post.ownerID!=post.authorID){ @@ -217,6 +224,11 @@ public T resolveNative(URI _link, Class expectedType, boolean allowFetchi ensureObjectIsInCollection(owner, owner.getWallURL(), post.getActivityPubID()); } } + if(o instanceof Post post){ + User author=context.getUsersController().getUserOrThrow(post.authorID); + if(author.banStatus==UserBanStatus.SUSPENDED) + throw new ObjectNotFoundException("Post author is suspended on this server"); + } if(allowStorage) storeOrUpdateRemoteObject(o); return o; @@ -259,9 +271,9 @@ public T resolveNative(URI _link, Class expectedType, boolean allowFetchi @NotNull public T resolve(URI _link, Class expectedType, boolean allowFetching, boolean allowStorage, boolean forceRefetch, JsonObject actorToken, boolean bypassCollectionCheck){ Class nativeType; - if(expectedType.isAssignableFrom(ActivityPubObject.class)){ + if(expectedType.isAssignableFrom(ActivityPubObject.class) && (allowStorage || Config.isLocal(_link))){ nativeType=Object.class; - }else if(NoteOrQuestion.class.isAssignableFrom(expectedType)){ + }else if(NoteOrQuestion.class.isAssignableFrom(expectedType) && (allowStorage || Config.isLocal(_link))){ nativeType=Post.class; }else{ nativeType=expectedType; diff --git a/src/main/java/smithereen/controllers/PrivacyController.java b/src/main/java/smithereen/controllers/PrivacyController.java index 0c6ddace..98aa54a3 100644 --- a/src/main/java/smithereen/controllers/PrivacyController.java +++ b/src/main/java/smithereen/controllers/PrivacyController.java @@ -19,6 +19,8 @@ import smithereen.ApplicationContext; import smithereen.activitypub.ActivityPub; import smithereen.activitypub.objects.Actor; +import smithereen.exceptions.InaccessibleProfileException; +import smithereen.exceptions.UserErrorException; import smithereen.model.ForeignGroup; import smithereen.model.ForeignUser; import smithereen.model.FriendshipStatus; @@ -28,7 +30,9 @@ import smithereen.model.OwnerAndAuthor; import smithereen.model.Post; import smithereen.model.PrivacySetting; +import smithereen.model.SessionInfo; import smithereen.model.User; +import smithereen.model.UserBanStatus; import smithereen.model.UserPrivacySettingKey; import smithereen.exceptions.BadRequestException; import smithereen.exceptions.UserContentUnavailableException; @@ -37,9 +41,10 @@ import smithereen.storage.GroupStorage; import smithereen.storage.MailStorage; import smithereen.storage.UserStorage; +import spark.Request; import spark.utils.StringUtils; -import static smithereen.Utils.escapeHTML; +import static smithereen.Utils.*; public class PrivacyController{ private static final Logger LOG=LoggerFactory.getLogger(PrivacyController.class); @@ -292,6 +297,10 @@ public boolean checkPostPrivacy(@Nullable User self, Post post){ } public void enforcePostPrivacy(@Nullable User self, Post post){ + if(post.ownerID>0){ + User owner=context.getUsersController().getUserOrThrow(post.ownerID); + enforceUserProfileAccess(self, owner); + } if(!checkPostPrivacy(self, post)) throw new UserContentUnavailableException(); } @@ -300,4 +309,16 @@ public void filterPosts(@Nullable User self, Collection posts){ // TODO optimize this to avoid querying the same friendship states multiple times posts.removeIf(post->!checkPostPrivacy(self, post)); } + + public void enforceUserProfileAccess(@Nullable User self, User target){ + switch(target.banStatus){ + case NONE -> {} + case FROZEN, SUSPENDED -> throw new UserErrorException("profile_banned"); + case HIDDEN -> { + if(self==null) + throw new InaccessibleProfileException(target); + } + case SELF_DEACTIVATED -> throw new UserErrorException("profile_deactivated"); + } + } } diff --git a/src/main/java/smithereen/controllers/UsersController.java b/src/main/java/smithereen/controllers/UsersController.java index c2aacb37..ee308975 100644 --- a/src/main/java/smithereen/controllers/UsersController.java +++ b/src/main/java/smithereen/controllers/UsersController.java @@ -1,8 +1,14 @@ package smithereen.controllers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.sql.SQLException; +import java.time.Instant; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Base64; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -10,11 +16,14 @@ import smithereen.ApplicationContext; import smithereen.Mailer; +import smithereen.SmithereenApplication; import smithereen.Utils; import smithereen.activitypub.objects.Actor; import smithereen.model.Account; +import smithereen.model.AuditLogEntry; import smithereen.model.ForeignUser; import smithereen.model.Group; +import smithereen.model.OtherSession; import smithereen.model.PaginatedList; import smithereen.model.SignupInvitation; import smithereen.model.SignupRequest; @@ -23,13 +32,22 @@ import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.exceptions.UserErrorException; +import smithereen.model.UserBanInfo; +import smithereen.model.UserBanStatus; +import smithereen.model.UserPermissions; +import smithereen.model.UserRole; +import smithereen.model.viewmodel.UserContentMetrics; +import smithereen.model.viewmodel.UserRelationshipMetrics; import smithereen.storage.DatabaseUtils; +import smithereen.storage.ModerationStorage; +import smithereen.storage.PostStorage; import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; import smithereen.util.FloodControl; import spark.Request; public class UsersController{ + private static final Logger LOG=LoggerFactory.getLogger(UsersController.class); private final ApplicationContext context; public UsersController(ApplicationContext context){ @@ -112,7 +130,8 @@ public void sendEmailInvite(Request req, Account self, String email, String firs if(SessionStorage.isEmailInvited(email)){ throw new UserErrorException("err_email_already_invited"); } - if(self.accessLevel!=Account.AccessLevel.ADMIN){ + UserPermissions permissions=SessionStorage.getUserPermissions(self); + if(!permissions.hasPermission(UserRole.Permission.MANAGE_INVITES)){ FloodControl.EMAIL_INVITE.incrementOrThrow(self); } int requestID=_requestID; @@ -140,7 +159,8 @@ public void resendEmailInvite(Request req, Account self, int id){ SignupInvitation invite=SessionStorage.getInvitationByID(id); if(invite==null || invite.ownerID!=self.id || invite.email==null) throw new ObjectNotFoundException(); - if(self.accessLevel!=Account.AccessLevel.ADMIN){ + UserPermissions permissions=SessionStorage.getUserPermissions(self); + if(!permissions.hasPermission(UserRole.Permission.MANAGE_INVITES)){ FloodControl.EMAIL_RESEND.incrementOrThrow(invite.email); } Mailer.getInstance().sendSignupInvitation(req, self, invite.email, invite.code, invite.firstName, invite.fromRequest); @@ -284,4 +304,151 @@ public void deleteForeignUser(ForeignUser user){ throw new InternalServerErrorException(x); } } + + public void deleteLocalUser(User admin, User user){ + if(user instanceof ForeignUser || (user.banStatus!=UserBanStatus.SELF_DEACTIVATED && user.banStatus!=UserBanStatus.SUSPENDED)) + throw new IllegalArgumentException(); + try{ + Account acc=SessionStorage.getAccountByUserID(user.id); + if(acc==null) + return; + context.getActivityPubWorker().sendUserDeleteSelf(user); + UserStorage.deleteAccount(acc); + SmithereenApplication.invalidateAllSessionsForAccount(acc.id); + if(admin!=null) + ModerationStorage.createAuditLogEntry(admin.id, AuditLogEntry.Action.DELETE_USER, user.id, 0, null, Map.of("name", user.getCompleteName())); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public Account getAccountOrThrow(int id){ + try{ + Account acc=UserStorage.getAccount(id); + if(acc==null) + throw new ObjectNotFoundException("err_user_not_found"); + return acc; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public Account getAccountForUser(User user){ + try{ + Account acc=SessionStorage.getAccountByUserID(user.id); + if(acc==null) + throw new ObjectNotFoundException(); + return acc; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public UserRelationshipMetrics getRelationshipMetrics(User user){ + try{ + return new UserRelationshipMetrics( + UserStorage.getUserFriendsCount(user.id), + UserStorage.getUserFollowerOrFollowingCount(user.id, true), + UserStorage.getUserFollowerOrFollowingCount(user.id, false) + ); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public UserContentMetrics getContentMetrics(User user){ + try{ + return new UserContentMetrics( + PostStorage.getUserPostCount(user.id), + PostStorage.getUserPostCommentCount(user.id) + ); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public List getAccountSessions(Account acc){ + try{ + return SessionStorage.getAccountSessions(acc.id); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void changePassword(Account self, String oldPassword, String newPassword){ + try{ + if(newPassword.length()<4){ + throw new UserErrorException("err_password_short"); + }else if(!SessionStorage.updatePassword(self.id, oldPassword, newPassword)){ + throw new UserErrorException("err_old_password_incorrect"); + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public boolean checkPassword(Account self, String password){ + try{ + return SessionStorage.checkPassword(self.id, password); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void terminateSessionsExcept(Account self, String psid){ + try{ + byte[] sid=Base64.getDecoder().decode(psid); + SessionStorage.deleteSessionsExcept(self.id, sid); + SmithereenApplication.invalidateAllSessionsForAccount(self.id); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void selfDeactivateAccount(Account self){ + try{ + if(self.user.banStatus!=UserBanStatus.NONE) + throw new IllegalArgumentException("Already banned"); + UserBanInfo info=new UserBanInfo(Instant.now(), null, null, false, 0, 0); + UserStorage.setUserBanStatus(self.user, self, UserBanStatus.SELF_DEACTIVATED, Utils.gson.toJson(info)); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public void selfReinstateAccount(Account self){ + try{ + if(self.user.banStatus!=UserBanStatus.SELF_DEACTIVATED) + throw new IllegalArgumentException("Invalid account status"); + UserStorage.setUserBanStatus(self.user, self, UserBanStatus.NONE, null); + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public static void doPendingAccountDeletions(){ + try{ + ApplicationContext ctx=new ApplicationContext(); // This is probably gonna bite me in the ass in the future + List users=UserStorage.getTerminallyBannedUsers(); + if(users.isEmpty()){ + LOG.trace("No users to delete"); + return; + } + Instant deleteBannedBefore=Instant.now().minus(30, ChronoUnit.DAYS); + for(User user:users){ + if(user.banStatus!=UserBanStatus.SUSPENDED && user.banStatus!=UserBanStatus.SELF_DEACTIVATED){ + LOG.warn("Ineligible user {} in pending account deletions - bug likely (banStatus {}, banInfo {})", user.id, user.banStatus, user.banInfo); + continue; + } + if(user.banInfo.bannedAt().isBefore(deleteBannedBefore)){ + LOG.info("Deleting user {}, banStatus {}, banInfo {}", user.id, user.banStatus, user.banInfo); + ctx.getUsersController().deleteLocalUser(null, user); + }else{ + LOG.trace("User {} too early to delete", user.id); + } + } + }catch(SQLException x){ + LOG.error("Failed to delete accounts", x); + } + } } diff --git a/src/main/java/smithereen/controllers/WallController.java b/src/main/java/smithereen/controllers/WallController.java index 63cf3fe8..2a6e4acc 100644 --- a/src/main/java/smithereen/controllers/WallController.java +++ b/src/main/java/smithereen/controllers/WallController.java @@ -34,7 +34,6 @@ import smithereen.activitypub.objects.LocalImage; import smithereen.activitypub.objects.Mention; import smithereen.activitypub.objects.NoteOrQuestion; -import smithereen.model.Account; import smithereen.model.ForeignUser; import smithereen.model.FriendshipStatus; import smithereen.model.Group; @@ -49,7 +48,9 @@ import smithereen.model.UserInteractions; import smithereen.model.UserPermissions; import smithereen.model.UserPrivacySettingKey; +import smithereen.model.UserRole; import smithereen.model.feed.NewsfeedEntry; +import smithereen.model.media.MediaFileReferenceType; import smithereen.model.notifications.Notification; import smithereen.model.notifications.NotificationUtils; import smithereen.model.viewmodel.PostViewModel; @@ -57,7 +58,7 @@ import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.exceptions.UserActionNotAllowedException; -import smithereen.storage.MediaCache; +import smithereen.storage.MediaStorage; import smithereen.storage.MediaStorageUtils; import smithereen.storage.NotificationsStorage; import smithereen.storage.PostStorage; @@ -157,17 +158,7 @@ public Post createWallPost(@NotNull User author, int authorAccountID, @NotNull A String attachments=null; if(!attachmentIDs.isEmpty()){ ArrayList attachObjects=new ArrayList<>(); - for(String id:attachmentIDs){ - if(!id.matches("^[a-fA-F0-9]{32}$")) - continue; - ActivityPubObject obj=MediaCache.getAndDeleteDraftAttachment(id, authorAccountID, "post_media"); - if(obj!=null){ - attachObjects.add(obj); - attachmentCount++; - } - if(attachmentCount==maxAttachments) - break; - } + MediaStorageUtils.fillAttachmentObjects(attachObjects, attachmentIDs, attachmentCount, maxAttachments); if(!attachObjects.isEmpty()){ if(attachObjects.size()==1){ attachments=MediaStorageUtils.serializeAttachment(attachObjects.get(0)).toString(); @@ -234,6 +225,14 @@ public Post createWallPost(@NotNull User author, int authorAccountID, @NotNull A if(post==null) throw new IllegalStateException("?!"); + if(post.attachments!=null){ + for(ActivityPubObject att:post.attachments){ + if(att instanceof LocalImage li){ + MediaStorage.createMediaFileReference(li.fileID, post.id, MediaFileReferenceType.WALL_ATTACHMENT, post.getOwnerID()); + } + } + } + // Add{Note} is sent for any wall posts & comments on them, for local wall owners. // Create{Note} is sent for anything else. if((ownerGroupID!=0 || ownerUserID!=userID) && !isTopLevelPostOwn && !(wallOwner instanceof ForeignActor)){ @@ -383,13 +382,14 @@ public Post editPost(@NotNull User self, @NotNull UserPermissions permissions, i if(!attachmentIDs.isEmpty()){ ArrayList attachObjects=new ArrayList<>(); - ArrayList remainingAttachments=new ArrayList<>(attachmentIDs); + ArrayList newlyAddedAttachments=new ArrayList<>(attachmentIDs); if(post.attachments!=null){ for(ActivityPubObject att:post.attachments){ if(att instanceof LocalImage li){ - if(!remainingAttachments.remove(li.localID)){ - LOG.debug("Deleting attachment: {}", li.localID); - MediaStorageUtils.deleteAttachmentFiles(li); + String localID=li.fileRecord.id().getIDForClient(); + if(!newlyAddedAttachments.remove(localID)){ + LOG.debug("Deleting attachment: {}", localID); + MediaStorage.deleteMediaFileReference(post.id, MediaFileReferenceType.WALL_ATTACHMENT, li.fileID); }else{ attachObjects.add(li); } @@ -399,17 +399,12 @@ public Post editPost(@NotNull User self, @NotNull UserPermissions permissions, i } } - if(!remainingAttachments.isEmpty()){ - for(String aid : remainingAttachments){ - if(!aid.matches("^[a-fA-F0-9]{32}$")) - continue; - ActivityPubObject obj=MediaCache.getAndDeleteDraftAttachment(aid, post.authorID, "post_media"); - if(obj!=null){ - attachObjects.add(obj); - attachmentCount++; + if(!newlyAddedAttachments.isEmpty()){ + MediaStorageUtils.fillAttachmentObjects(attachObjects, newlyAddedAttachments, attachmentCount, maxAttachments); + for(ActivityPubObject att:attachObjects){ + if(att instanceof LocalImage li && newlyAddedAttachments.contains(li.fileRecord.id().getIDForClient())){ + MediaStorage.createMediaFileReference(li.fileID, post.id, MediaFileReferenceType.WALL_ATTACHMENT, post.ownerID); } - if(attachmentCount==maxAttachments) - break; } } if(!attachObjects.isEmpty()){ @@ -638,17 +633,18 @@ private void deletePostInternal(@NotNull SessionInfo info, Post post, boolean ig } PostStorage.deletePost(post.id); NotificationsStorage.deleteNotificationsForObject(Notification.ObjectType.POST, post.id); - if(post.isLocal() && post.attachments!=null && !post.attachments.isEmpty()){ - MediaStorageUtils.deleteAttachmentFiles(post.attachments); - } context.getNewsfeedController().clearFriendsFeedCache(); User deleteActor=info.account.user; OwnerAndAuthor oaa=getContentAuthorAndOwner(post); // if the current user is a moderator, and the post isn't made or owned by them, send the deletion as if the author deleted the post themselves - if(info.account.accessLevel.ordinal()>=Account.AccessLevel.MODERATOR.ordinal() && oaa.author().id!=info.account.user.id && !post.isGroupOwner() && post.ownerID!=info.account.user.id && !(oaa.author() instanceof ForeignUser)){ + if(ignorePermissions && oaa.author().id!=info.account.user.id && !post.isGroupOwner() && post.ownerID!=info.account.user.id && !(oaa.author() instanceof ForeignUser)){ deleteActor=oaa.author(); } context.getActivityPubWorker().sendDeletePostActivity(post, deleteActor); + + if(post.isLocal() && post.attachments!=null){ + MediaStorage.deleteMediaFileReferences(post.id, MediaFileReferenceType.WALL_ATTACHMENT); + } }catch(SQLException x){ throw new InternalServerErrorException(x); } @@ -664,7 +660,7 @@ public List getPollOptionVoters(PollOption option, int offset, int count){ public void setPostCWAsModerator(@NotNull UserPermissions permissions, Post post, String cw){ try{ - if(permissions.serverAccessLevel.compareTo(Account.AccessLevel.MODERATOR)<0) + if(!permissions.hasPermission(UserRole.Permission.MANAGE_REPORTS)) throw new UserActionNotAllowedException(); PostStorage.updateWallPostCW(post.id, cw); diff --git a/src/main/java/smithereen/exceptions/InaccessibleProfileException.java b/src/main/java/smithereen/exceptions/InaccessibleProfileException.java new file mode 100644 index 00000000..edf77582 --- /dev/null +++ b/src/main/java/smithereen/exceptions/InaccessibleProfileException.java @@ -0,0 +1,11 @@ +package smithereen.exceptions; + +import smithereen.model.User; + +public class InaccessibleProfileException extends RuntimeException{ + public final User user; + + public InaccessibleProfileException(User user){ + this.user=user; + } +} diff --git a/src/main/java/smithereen/http/ExtendedHttpClient.java b/src/main/java/smithereen/http/ExtendedHttpClient.java new file mode 100644 index 00000000..23d5b057 --- /dev/null +++ b/src/main/java/smithereen/http/ExtendedHttpClient.java @@ -0,0 +1,130 @@ +package smithereen.http; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +import smithereen.BuildInfo; +import smithereen.Config; + +public class ExtendedHttpClient extends HttpClient{ + private final HttpClient realClient; + + ExtendedHttpClient(HttpClient realClient){ + this.realClient=realClient; + } + + public static Builder newBuilder(){ + return new ExtendedHttpClientBuilder(HttpClient.newBuilder()); + } + + @Override + public Optional cookieHandler(){ + return realClient.cookieHandler(); + } + + @Override + public Optional connectTimeout(){ + return realClient.connectTimeout(); + } + + @Override + public Redirect followRedirects(){ + return realClient.followRedirects(); + } + + @Override + public Optional proxy(){ + return realClient.proxy(); + } + + @Override + public SSLContext sslContext(){ + return realClient.sslContext(); + } + + @Override + public SSLParameters sslParameters(){ + return realClient.sslParameters(); + } + + @Override + public Optional authenticator(){ + return realClient.authenticator(); + } + + @Override + public Version version(){ + return realClient.version(); + } + + @Override + public Optional executor(){ + return realClient.executor(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) throws IOException, InterruptedException{ + return realClient.send(maybeAddUserAgent(request), responseBodyHandler); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler){ + return realClient.sendAsync(maybeAddUserAgent(request), responseBodyHandler); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler, HttpResponse.PushPromiseHandler pushPromiseHandler){ + return realClient.sendAsync(maybeAddUserAgent(request), responseBodyHandler, pushPromiseHandler); + } + + @Override + public WebSocket.Builder newWebSocketBuilder(){ + return realClient.newWebSocketBuilder(); + } + + @Override + public void shutdown(){ + realClient.shutdown(); + } + + @Override + public boolean awaitTermination(Duration duration) throws InterruptedException{ + return realClient.awaitTermination(duration); + } + + @Override + public boolean isTerminated(){ + return realClient.isTerminated(); + } + + @Override + public void shutdownNow(){ + realClient.shutdownNow(); + } + + @Override + public void close(){ + realClient.close(); + } + + private HttpRequest maybeAddUserAgent(HttpRequest req){ + if(req.headers().firstValue("user-agent").isPresent()) + return req; + return HttpRequest.newBuilder(req, (k, v)->true) + .header("User-Agent", "Smithereen/"+BuildInfo.VERSION+" +https://"+Config.domain+"/") + .build(); + } +} diff --git a/src/main/java/smithereen/http/ExtendedHttpClientBuilder.java b/src/main/java/smithereen/http/ExtendedHttpClientBuilder.java new file mode 100644 index 00000000..6aa3ac05 --- /dev/null +++ b/src/main/java/smithereen/http/ExtendedHttpClientBuilder.java @@ -0,0 +1,84 @@ +package smithereen.http; + +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.concurrent.Executor; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +class ExtendedHttpClientBuilder implements HttpClient.Builder{ + private final HttpClient.Builder realBuilder; + + public ExtendedHttpClientBuilder(HttpClient.Builder realBuilder){ + this.realBuilder=realBuilder; + } + + @Override + public HttpClient.Builder cookieHandler(CookieHandler cookieHandler){ + realBuilder.cookieHandler(cookieHandler); + return this; + } + + @Override + public HttpClient.Builder connectTimeout(Duration duration){ + realBuilder.connectTimeout(duration); + return this; + } + + @Override + public HttpClient.Builder sslContext(SSLContext sslContext){ + realBuilder.sslContext(sslContext); + return this; + } + + @Override + public HttpClient.Builder sslParameters(SSLParameters sslParameters){ + realBuilder.sslParameters(sslParameters); + return this; + } + + @Override + public HttpClient.Builder executor(Executor executor){ + realBuilder.executor(executor); + return this; + } + + @Override + public HttpClient.Builder followRedirects(HttpClient.Redirect policy){ + realBuilder.followRedirects(policy); + return this; + } + + @Override + public HttpClient.Builder version(HttpClient.Version version){ + realBuilder.version(version); + return this; + } + + @Override + public HttpClient.Builder priority(int priority){ + realBuilder.priority(priority); + return this; + } + + @Override + public HttpClient.Builder proxy(ProxySelector proxySelector){ + realBuilder.proxy(proxySelector); + return this; + } + + @Override + public HttpClient.Builder authenticator(Authenticator authenticator){ + realBuilder.authenticator(authenticator); + return this; + } + + @Override + public HttpClient build(){ + return new ExtendedHttpClient(realBuilder.build()); + } +} diff --git a/src/main/java/smithereen/http/FormBodyPublisherBuilder.java b/src/main/java/smithereen/http/FormBodyPublisherBuilder.java new file mode 100644 index 00000000..f042f4d0 --- /dev/null +++ b/src/main/java/smithereen/http/FormBodyPublisherBuilder.java @@ -0,0 +1,28 @@ +package smithereen.http; + +import java.net.URLEncoder; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import smithereen.storage.utils.Pair; + +public class FormBodyPublisherBuilder{ + public static final String CONTENT_TYPE="application/x-www-form-urlencoded"; + + private List> fields=new ArrayList<>(); + + public FormBodyPublisherBuilder add(String key, String value){ + fields.add(new Pair<>(key, value)); + return this; + } + + public HttpRequest.BodyPublisher build(){ + String body=fields.stream() + .map(f->URLEncoder.encode(f.first(), StandardCharsets.UTF_8)+"="+URLEncoder.encode(f.second(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + return HttpRequest.BodyPublishers.ofString(body); + } +} diff --git a/src/main/java/smithereen/http/HttpContentType.java b/src/main/java/smithereen/http/HttpContentType.java new file mode 100644 index 00000000..6a7b6b7b --- /dev/null +++ b/src/main/java/smithereen/http/HttpContentType.java @@ -0,0 +1,39 @@ +package smithereen.http; + +import java.net.http.HttpHeaders; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Objects; + +public record HttpContentType(String mimeType, Map attributes){ + public static HttpContentType from(HttpHeaders headers){ + String contentType=headers.firstValue("content-type").orElse("text/html; charset=UTF-8"); + int index=contentType.indexOf(';'); + if(index==-1){ + return new HttpContentType(contentType, Map.of()); + } + return new HttpContentType(contentType.substring(0, index), HttpHeaderParser.parseAttributes(contentType.substring(index+1))); + } + + public Charset getCharset(){ + if(attributes.containsKey("charset")){ + Charset.forName(attributes.get("charset"), StandardCharsets.UTF_8); + } + return StandardCharsets.UTF_8; + } + + public boolean matches(HttpContentType other){ + if(!mimeType.equalsIgnoreCase(other.mimeType)) + return false; + for(Map.Entry attr:other.attributes.entrySet()){ + if(!Objects.equals(attr.getValue(), attributes.get(attr.getKey()))) + return false; + } + return true; + } + + public boolean matches(String mimeType){ + return mimeType.equalsIgnoreCase(this.mimeType); + } +} diff --git a/src/main/java/smithereen/http/HttpHeaderParser.java b/src/main/java/smithereen/http/HttpHeaderParser.java new file mode 100644 index 00000000..8574e604 --- /dev/null +++ b/src/main/java/smithereen/http/HttpHeaderParser.java @@ -0,0 +1,25 @@ +package smithereen.http; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class HttpHeaderParser{ + private static final Pattern ATTR_REGEX=Pattern.compile("(\\S+)=(\"(?:[^\"\\\\]|\\\\\"|\\\\\\\\)*\"|\\S+)(?:; *|$)"); + + public static Map parseAttributes(String raw){ + HashMap res=new HashMap<>(); + raw=raw.trim(); + Matcher matcher=ATTR_REGEX.matcher(raw); + while(matcher.find()){ + String key=matcher.group(1); + String value=matcher.group(2); + if(value.charAt(0)=='"'){ + value=value.substring(1, value.length()-1).replace("\\", ""); + } + res.put(key.toLowerCase(), value); + } + return res; + } +} diff --git a/src/main/java/smithereen/http/ReaderBodyHandler.java b/src/main/java/smithereen/http/ReaderBodyHandler.java new file mode 100644 index 00000000..ed96b73b --- /dev/null +++ b/src/main/java/smithereen/http/ReaderBodyHandler.java @@ -0,0 +1,12 @@ +package smithereen.http; + +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.http.HttpResponse; + +public class ReaderBodyHandler implements HttpResponse.BodyHandler{ + @Override + public HttpResponse.BodySubscriber apply(HttpResponse.ResponseInfo responseInfo){ + return HttpResponse.BodySubscribers.mapping(HttpResponse.BodySubscribers.ofInputStream(), in->new InputStreamReader(in, HttpContentType.from(responseInfo.headers()).getCharset())); + } +} diff --git a/src/main/java/smithereen/lang/Lang.java b/src/main/java/smithereen/lang/Lang.java index 709f0bad..58fca3c0 100644 --- a/src/main/java/smithereen/lang/Lang.java +++ b/src/main/java/smithereen/lang/Lang.java @@ -12,6 +12,8 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -50,7 +52,6 @@ public static Lang get(Locale locale){ list=new ArrayList<>(); try(InputStream in=Lang.class.getClassLoader().getResourceAsStream("langs/index.json")){ IndexFile index=Utils.gson.fromJson(new InputStreamReader(in), IndexFile.class); - System.out.println(index); for(IndexLanguage lang:index.languages){ try{ Lang l=new Lang(lang.locale, lang.name, lang.fallback, index.files); @@ -82,6 +83,7 @@ public static Lang get(Locale locale){ public String name; public final String englishName; private final Locale fallbackLocale; + private final NumberFormat fileSizeFormatter; private Lang(String localeID, String englishName, String fallbackLocaleID, List files) throws IOException{ locale=Locale.forLanguageTag(localeID); @@ -132,6 +134,8 @@ private Lang(String localeID, String englishName, String fallbackLocaleID, List< } } name=data.get("lang_name").toString(); + fileSizeFormatter=DecimalFormat.getNumberInstance(locale); + fileSizeFormatter.setMaximumFractionDigits(2); } public String get(String key){ @@ -199,6 +203,8 @@ public String formatDate(Instant date, ZoneId timeZone, boolean forceAbsolute){ long ts=date.toEpochMilli(); long tsNow=System.currentTimeMillis(); long diff=tsNow-ts; + if(timeZone==null) + timeZone=ZoneId.systemDefault(); if(!forceAbsolute){ if(diff>=0 && diff<60_000){ @@ -251,6 +257,28 @@ public String formatTimeOrDay(Instant time, ZoneId timeZone){ } } + public String formatFileSize(long size){ + String key; + double amount; + if(size<1024){ + key="file_size_bytes"; + amount=size; + }else if(size<1024L*1024){ + key="file_size_kilobytes"; + amount=size/1024.0; + }else if(size<1024L*1024*1024){ + key="file_size_megabytes"; + amount=size/(1024.0*1024.0); + }else if(size<1024L*1024*1024*1024){ + key="file_size_gigabytes"; + amount=size/(1024.0*1024.0*1024.0); + }else{ + key="file_size_terabytes"; + amount=size/(1024.0*1024.0*1024.0*1024.0); + } + return get(key, Map.of("amount", fileSizeFormatter.format(amount))); + } + public String getAsJS(String key){ if(!data.containsKey(key)){ if(fallback!=null) diff --git a/src/main/java/smithereen/model/Account.java b/src/main/java/smithereen/model/Account.java index bca130b5..1cc38ee9 100644 --- a/src/main/java/smithereen/model/Account.java +++ b/src/main/java/smithereen/model/Account.java @@ -2,10 +2,12 @@ import com.google.gson.JsonParseException; +import java.net.InetAddress; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; +import smithereen.Config; import smithereen.Utils; import smithereen.storage.DatabaseUtils; import smithereen.storage.UserStorage; @@ -14,14 +16,14 @@ public class Account{ public int id; public String email; public User user; - public AccessLevel accessLevel; public UserPreferences prefs; public Instant createdAt; public Instant lastActive; - public BanInfo banInfo; public ActivationInfo activationInfo; - - public User invitedBy; // used in admin UIs + public int roleID; + public int promotedBy; + public InetAddress lastIP; + public int inviterAccountID; @Override public String toString(){ @@ -29,13 +31,14 @@ public String toString(){ "id="+id+ ", email='"+email+'\''+ ", user="+user+ - ", accessLevel="+accessLevel+ ", prefs="+prefs+ ", createdAt="+createdAt+ ", lastActive="+lastActive+ - ", banInfo="+banInfo+ ", activationInfo="+activationInfo+ - ", invitedBy="+invitedBy+ + ", roleID="+roleID+ + ", promotedBy="+promotedBy+ + ", lastIP="+lastIP+ + ", inviterAccountID="+inviterAccountID+ '}'; } @@ -43,13 +46,11 @@ public static Account fromResultSet(ResultSet res) throws SQLException{ Account acc=new Account(); acc.id=res.getInt("id"); acc.email=res.getString("email"); - acc.accessLevel=AccessLevel.values()[res.getInt("access_level")]; acc.user=UserStorage.getById(res.getInt("user_id")); acc.createdAt=DatabaseUtils.getInstant(res, "created_at"); acc.lastActive=DatabaseUtils.getInstant(res, "last_active"); - String ban=res.getString("ban_info"); - if(ban!=null) - acc.banInfo=Utils.gson.fromJson(ban, BanInfo.class); + acc.lastIP=Utils.deserializeInetAddress(res.getBytes("last_ip")); + acc.inviterAccountID=res.getInt("invited_by"); String prefs=res.getString("preferences"); if(prefs==null){ acc.prefs=new UserPreferences(); @@ -63,6 +64,8 @@ public static Account fromResultSet(ResultSet res) throws SQLException{ String activation=res.getString("activation_info"); if(activation!=null) acc.activationInfo=Utils.gson.fromJson(activation, ActivationInfo.class); + acc.roleID=res.getInt("role"); + acc.promotedBy=res.getInt("promoted_by"); return acc; } @@ -84,11 +87,8 @@ public String getCurrentEmailMasked(){ return user.substring(0, count)+"*".repeat(user.length()-count)+"@"+parts[1]; } - public enum AccessLevel{ - BANNED, - REGULAR, - MODERATOR, - ADMIN + public String getEmailDomain(){ + return email.substring(email.indexOf('@')+1); } public static class BanInfo{ diff --git a/src/main/java/smithereen/model/ActorStaffNote.java b/src/main/java/smithereen/model/ActorStaffNote.java new file mode 100644 index 00000000..14e28a47 --- /dev/null +++ b/src/main/java/smithereen/model/ActorStaffNote.java @@ -0,0 +1,19 @@ +package smithereen.model; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.storage.DatabaseUtils; + +public record ActorStaffNote(int id, int targetID, int authorID, String text, Instant time){ + public static ActorStaffNote fromResultSet(ResultSet res) throws SQLException{ + return new ActorStaffNote( + res.getInt("id"), + res.getInt("target_id"), + res.getInt("author_id"), + res.getString("text"), + DatabaseUtils.getInstant(res, "time") + ); + } +} diff --git a/src/main/java/smithereen/model/AuditLogEntry.java b/src/main/java/smithereen/model/AuditLogEntry.java new file mode 100644 index 00000000..9758043c --- /dev/null +++ b/src/main/java/smithereen/model/AuditLogEntry.java @@ -0,0 +1,63 @@ +package smithereen.model; + +import com.google.gson.reflect.TypeToken; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Map; + +import smithereen.Utils; +import smithereen.storage.DatabaseUtils; + +public record AuditLogEntry(int id, int adminID, Action action, Instant time, int ownerID, long objectID, ObjectType objectType, Map extra){ + public enum Action{ + // Roles + CREATE_ROLE, + EDIT_ROLE, + DELETE_ROLE, + ASSIGN_ROLE, + + // Users + ACTIVATE_ACCOUNT, + SET_USER_EMAIL, + RESET_USER_PASSWORD, + END_USER_SESSION, + BAN_USER, + DELETE_USER, + + // Blocking rules + CREATE_EMAIL_DOMAIN_RULE, + UPDATE_EMAIL_DOMAIN_RULE, + DELETE_EMAIL_DOMAIN_RULE, + CREATE_IP_RULE, + UPDATE_IP_RULE, + DELETE_IP_RULE, + + // Invites + DELETE_SIGNUP_INVITE, + } + + public enum ObjectType{ + ROLE, + POST, + REPORT, + SIGNUP_INVITE, + } + + public static AuditLogEntry fromResultSet(ResultSet res) throws SQLException{ + int _type=res.getInt("object_type"); + ObjectType objType=res.wasNull() ? null : ObjectType.values()[_type]; + String extra=res.getString("extra"); + return new AuditLogEntry( + res.getInt("id"), + res.getInt("admin_id"), + Action.values()[res.getInt("action")], + DatabaseUtils.getInstant(res, "time"), + res.getInt("owner_id"), + res.getLong("object_id"), + objType, + extra==null ? null : Utils.gson.fromJson(extra, new TypeToken<>(){}) + ); + } +} diff --git a/src/main/java/smithereen/model/CaptchaInfo.java b/src/main/java/smithereen/model/CaptchaInfo.java new file mode 100644 index 00000000..0349f644 --- /dev/null +++ b/src/main/java/smithereen/model/CaptchaInfo.java @@ -0,0 +1,6 @@ +package smithereen.model; + +import java.time.Instant; + +public record CaptchaInfo(String answer, Instant generatedAt){ +} diff --git a/src/main/java/smithereen/model/EmailDomainBlockRule.java b/src/main/java/smithereen/model/EmailDomainBlockRule.java new file mode 100644 index 00000000..29b5d2f8 --- /dev/null +++ b/src/main/java/smithereen/model/EmailDomainBlockRule.java @@ -0,0 +1,33 @@ +package smithereen.model; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import smithereen.util.TranslatableEnum; + +public record EmailDomainBlockRule(String domain, Action action){ + + public static EmailDomainBlockRule fromResultSet(ResultSet res) throws SQLException{ + return new EmailDomainBlockRule( + res.getString("domain"), + Action.values()[res.getInt("action")] + ); + } + + public boolean matches(String domain){ + return this.domain.equalsIgnoreCase(domain); + } + + public enum Action implements TranslatableEnum{ + MANUAL_REVIEW, + BLOCK; + + @Override + public String getLangKey(){ + return switch(this){ + case MANUAL_REVIEW -> "admin_email_rule_review"; + case BLOCK -> "admin_email_rule_reject"; + }; + } + } +} diff --git a/src/main/java/smithereen/model/EmailDomainBlockRuleFull.java b/src/main/java/smithereen/model/EmailDomainBlockRuleFull.java new file mode 100644 index 00000000..135fdfd6 --- /dev/null +++ b/src/main/java/smithereen/model/EmailDomainBlockRuleFull.java @@ -0,0 +1,18 @@ +package smithereen.model; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.storage.DatabaseUtils; + +public record EmailDomainBlockRuleFull(EmailDomainBlockRule rule, Instant createdAt, String note, int creatorID){ + public static EmailDomainBlockRuleFull fromResultSet(ResultSet res) throws SQLException{ + return new EmailDomainBlockRuleFull( + EmailDomainBlockRule.fromResultSet(res), + DatabaseUtils.getInstant(res, "created_at"), + res.getString("note"), + res.getInt("creator_id") + ); + } +} diff --git a/src/main/java/smithereen/model/ForeignGroup.java b/src/main/java/smithereen/model/ForeignGroup.java index 4dd60614..e4d2972f 100644 --- a/src/main/java/smithereen/model/ForeignGroup.java +++ b/src/main/java/smithereen/model/ForeignGroup.java @@ -18,6 +18,7 @@ import smithereen.activitypub.objects.Event; import smithereen.activitypub.objects.ForeignActor; import smithereen.exceptions.BadRequestException; +import smithereen.storage.DatabaseUtils; import spark.utils.StringUtils; public class ForeignGroup extends Group implements ForeignActor{ @@ -42,7 +43,7 @@ protected void fillFromResultSet(ResultSet res) throws SQLException{ url=tryParseURL(res.getString("ap_url")); inbox=tryParseURL(res.getString("ap_inbox")); sharedInbox=tryParseURL(res.getString("ap_shared_inbox")); - lastUpdated=res.getTimestamp("last_updated"); + lastUpdated=DatabaseUtils.getInstant(res, "last_updated"); Utils.deserializeEnumSet(capabilities, ForeignGroup.Capability.class, res.getLong("flags")); EndpointsStorageWrapper ep=Utils.gson.fromJson(res.getString("endpoints"), EndpointsStorageWrapper.class); @@ -171,7 +172,7 @@ public void storeDependencies(ApplicationContext context){ @Override public boolean needUpdate(){ - return lastUpdated!=null && System.currentTimeMillis()-lastUpdated.getTime()>24L*60*60*1000; + return lastUpdated!=null && System.currentTimeMillis()-lastUpdated.toEpochMilli()>24L*60*60*1000; } @Override diff --git a/src/main/java/smithereen/model/ForeignUser.java b/src/main/java/smithereen/model/ForeignUser.java index 63f9a34b..fb3bdc03 100644 --- a/src/main/java/smithereen/model/ForeignUser.java +++ b/src/main/java/smithereen/model/ForeignUser.java @@ -10,6 +10,7 @@ import java.time.LocalDate; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -19,8 +20,10 @@ import smithereen.activitypub.ParserContext; import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.ForeignActor; +import smithereen.activitypub.objects.LinkOrObject; import smithereen.controllers.ObjectLinkResolver; import smithereen.jsonld.JLD; +import smithereen.storage.DatabaseUtils; import spark.utils.StringUtils; public class ForeignUser extends User implements ForeignActor{ @@ -49,7 +52,7 @@ protected void fillFromResultSet(ResultSet res) throws SQLException{ url=tryParseURL(res.getString("ap_url")); inbox=tryParseURL(res.getString("ap_inbox")); sharedInbox=tryParseURL(res.getString("ap_shared_inbox")); - lastUpdated=res.getTimestamp("last_updated"); + lastUpdated=DatabaseUtils.getInstant(res, "last_updated"); EndpointsStorageWrapper ep=Utils.gson.fromJson(res.getString("endpoints"), EndpointsStorageWrapper.class); outbox=tryParseURL(ep.outbox); @@ -228,11 +231,13 @@ protected ActivityPubObject parseActivityPubObject(JsonObject obj, ParserContext movedToURL=tryParseURL(obj.get("movedTo").getAsString()); } if(obj.has("alsoKnownAs")){ - alsoKnownAs=tryParseArrayOfLinksOrObjects(obj.get("alsoKnownAs"), parserContext) - .stream() - .filter(l->l.link!=null) - .map(l->l.link) - .collect(Collectors.toSet()); + List aka=tryParseArrayOfLinksOrObjects(obj.get("alsoKnownAs"), parserContext); + if(aka!=null){ + alsoKnownAs=aka.stream() + .filter(l->l.link!=null) + .map(l->l.link) + .collect(Collectors.toSet()); + } } return this; @@ -275,7 +280,7 @@ protected NonCachedRemoteImage.Args getAvatarArgs(){ @Override public boolean needUpdate(){ - return lastUpdated!=null && System.currentTimeMillis()-lastUpdated.getTime()>24L*60*60*1000; + return lastUpdated!=null && System.currentTimeMillis()-lastUpdated.toEpochMilli()>24L*60*60*1000; } @Override diff --git a/src/main/java/smithereen/model/IPBlockRule.java b/src/main/java/smithereen/model/IPBlockRule.java new file mode 100644 index 00000000..8f9e4539 --- /dev/null +++ b/src/main/java/smithereen/model/IPBlockRule.java @@ -0,0 +1,35 @@ +package smithereen.model; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.Utils; +import smithereen.storage.DatabaseUtils; +import smithereen.util.InetAddressRange; +import smithereen.util.TranslatableEnum; + +public record IPBlockRule(int id, InetAddressRange ipRange, Action action, Instant expiresAt){ + + public static IPBlockRule fromResultSet(ResultSet res) throws SQLException{ + return new IPBlockRule( + res.getInt("id"), + new InetAddressRange(Utils.deserializeInetAddress(res.getBytes("address")), res.getInt("prefix_length")), + Action.values()[res.getInt("action")], + DatabaseUtils.getInstant(res, "expires_at") + ); + } + + public enum Action implements TranslatableEnum{ + MANUAL_REVIEW_SIGNUPS, + BLOCK_SIGNUPS; + + @Override + public String getLangKey(){ + return switch(this){ + case MANUAL_REVIEW_SIGNUPS -> "admin_email_rule_review"; + case BLOCK_SIGNUPS -> "admin_email_rule_reject"; + }; + } + } +} diff --git a/src/main/java/smithereen/model/IPBlockRuleFull.java b/src/main/java/smithereen/model/IPBlockRuleFull.java new file mode 100644 index 00000000..7d08f55d --- /dev/null +++ b/src/main/java/smithereen/model/IPBlockRuleFull.java @@ -0,0 +1,18 @@ +package smithereen.model; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.storage.DatabaseUtils; + +public record IPBlockRuleFull(IPBlockRule rule, Instant createdAt, int creatorID, String note){ + public static IPBlockRuleFull fromResultSet(ResultSet res) throws SQLException{ + return new IPBlockRuleFull( + IPBlockRule.fromResultSet(res), + DatabaseUtils.getInstant(res, "created_at"), + res.getInt("creator_id"), + res.getString("note") + ); + } +} diff --git a/src/main/java/smithereen/model/MailMessage.java b/src/main/java/smithereen/model/MailMessage.java index 5358ccf5..93f223b6 100644 --- a/src/main/java/smithereen/model/MailMessage.java +++ b/src/main/java/smithereen/model/MailMessage.java @@ -1,25 +1,31 @@ package smithereen.model; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.net.URI; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import smithereen.Config; import smithereen.Utils; import smithereen.activitypub.ParserContext; import smithereen.activitypub.objects.ActivityPubObject; import smithereen.storage.DatabaseUtils; +import smithereen.util.JsonArrayBuilder; +import smithereen.util.JsonObjectBuilder; import smithereen.util.XTEA; import spark.utils.StringUtils; -public class MailMessage implements AttachmentHostContentObject, ActivityPubRepresentable{ +public final class MailMessage implements AttachmentHostContentObject, ActivityPubRepresentable, ReportableContentObject{ public long id; public int senderID; public int ownerID; @@ -112,6 +118,42 @@ public int getTotalRecipientCount(){ return c; } + @Override + public JsonObject serializeForReport(int targetID, Set outFileIDs){ + if(ownerID!=targetID && senderID!=targetID) + return null; + JsonObjectBuilder jb=new JsonObjectBuilder() + .add("type", "message") + .add("id", XTEA.deobfuscateObjectID(id, ObfuscatedObjectIDType.MAIL_MESSAGE)) + .add("sender", senderID) + .add("to", to.stream().collect(JsonArrayBuilder.COLLECTOR)) + .add("created_at", createdAt.getEpochSecond()) + .add("text", text); + if(replyInfo!=null) + jb.add("replyInfo", Utils.gson.toJsonTree(replyInfo)); + if(attachments!=null && !attachments.isEmpty()) + jb.add("attachments", ReportableContentObject.serializeMediaAttachments(attachments, outFileIDs)); + return jb.build(); + } + + @Override + public void fillFromReport(JsonObject jo){ + id=jo.get("id").getAsLong(); + senderID=jo.get("sender").getAsInt(); + to=StreamSupport.stream(jo.getAsJsonArray("to").spliterator(), false).map(JsonElement::getAsInt).collect(Collectors.toSet()); + createdAt=Instant.ofEpochSecond(jo.get("created_at").getAsLong()); + text=jo.get("text").getAsString(); + if(jo.has("replyInfo")){ + replyInfo=Utils.gson.fromJson(jo.get("replyInfo"), ReplyInfo.class); + } + if(jo.has("attachments")){ + attachments=new ArrayList<>(); + for(JsonElement jatt:jo.getAsJsonArray("attachments")){ + attachments.add(ActivityPubObject.parse(jatt.getAsJsonObject(), ParserContext.LOCAL)); + } + } + } + public enum ParentObjectType{ MESSAGE, POST; diff --git a/src/main/java/smithereen/model/NonCachedRemoteImage.java b/src/main/java/smithereen/model/NonCachedRemoteImage.java index faca15a9..33700490 100644 --- a/src/main/java/smithereen/model/NonCachedRemoteImage.java +++ b/src/main/java/smithereen/model/NonCachedRemoteImage.java @@ -3,6 +3,7 @@ import java.net.URI; import smithereen.Utils; +import smithereen.util.UriBuilder; public class NonCachedRemoteImage implements SizedImage{ diff --git a/src/main/java/smithereen/model/ObfuscatedObjectIDType.java b/src/main/java/smithereen/model/ObfuscatedObjectIDType.java index 932f1abb..865bb8c1 100644 --- a/src/main/java/smithereen/model/ObfuscatedObjectIDType.java +++ b/src/main/java/smithereen/model/ObfuscatedObjectIDType.java @@ -1,5 +1,6 @@ package smithereen.model; public enum ObfuscatedObjectIDType{ - MAIL_MESSAGE + MAIL_MESSAGE, + MEDIA_FILE } diff --git a/src/main/java/smithereen/model/OtherSession.java b/src/main/java/smithereen/model/OtherSession.java new file mode 100644 index 00000000..dc70fdc5 --- /dev/null +++ b/src/main/java/smithereen/model/OtherSession.java @@ -0,0 +1,19 @@ +package smithereen.model; + +import java.net.InetAddress; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.Utils; +import smithereen.storage.DatabaseUtils; +import smithereen.util.uaparser.BrowserInfo; +import smithereen.util.uaparser.UserAgentParser; + +public record OtherSession(int id, InetAddress ip, Instant lastActive, String userAgent, BrowserInfo browserInfo, byte[] fullID){ + public static OtherSession fromResultSet(ResultSet res) throws SQLException{ + byte[] id=res.getBytes("id"); + String ua=res.getString("user_agent_str"); + return new OtherSession((int)Utils.unpackLong(id), Utils.deserializeInetAddress(res.getBytes("ip")), DatabaseUtils.getInstant(res, "last_active"), ua, UserAgentParser.parse(ua), id); + } +} diff --git a/src/main/java/smithereen/model/PaginatedList.java b/src/main/java/smithereen/model/PaginatedList.java index 1ffaddf3..c5e14181 100644 --- a/src/main/java/smithereen/model/PaginatedList.java +++ b/src/main/java/smithereen/model/PaginatedList.java @@ -22,6 +22,10 @@ public PaginatedList(List list, int total, int offset, int perPage){ this.perPage=perPage; } + public PaginatedList(PaginatedList other, List newItems){ + this(newItems, other.total, other.offset, other.perPage); + } + public static PaginatedList emptyList(int perPage){ return new PaginatedList<>(Collections.emptyList(), 0, 0, perPage); } diff --git a/src/main/java/smithereen/model/PollOption.java b/src/main/java/smithereen/model/PollOption.java index e70c0732..97a9eb28 100644 --- a/src/main/java/smithereen/model/PollOption.java +++ b/src/main/java/smithereen/model/PollOption.java @@ -4,6 +4,8 @@ import java.sql.ResultSet; import java.sql.SQLException; +import smithereen.util.UriBuilder; + public class PollOption{ public int id; public URI activityPubID; diff --git a/src/main/java/smithereen/model/Post.java b/src/main/java/smithereen/model/Post.java index 6efe2108..f4f551e0 100644 --- a/src/main/java/smithereen/model/Post.java +++ b/src/main/java/smithereen/model/Post.java @@ -1,5 +1,7 @@ package smithereen.model; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.net.URI; @@ -7,6 +9,7 @@ import java.sql.SQLException; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Set; @@ -17,9 +20,12 @@ import smithereen.activitypub.objects.ActivityPubObject; import smithereen.storage.DatabaseUtils; import smithereen.storage.PostStorage; +import smithereen.util.JsonArrayBuilder; +import smithereen.util.JsonObjectBuilder; +import smithereen.util.UriBuilder; import spark.utils.StringUtils; -public class Post implements ActivityPubRepresentable, OwnedContentObject, AttachmentHostContentObject{ +public final class Post implements ActivityPubRepresentable, OwnedContentObject, AttachmentHostContentObject, ReportableContentObject{ public int id; public int authorID; // userID or -groupID @@ -197,6 +203,64 @@ public NonCachedRemoteImage.Args getPhotoArgs(int index){ return new NonCachedRemoteImage.PostPhotoArgs(id, index); } + @Override + public JsonObject serializeForReport(int targetID, Set outFileIDs){ + if(authorID!=targetID && ownerID!=targetID) + return null; + JsonObjectBuilder jb=new JsonObjectBuilder() + .add("type", "post") + .add("id", id) + .add("owner", ownerID) + .add("author", authorID) + .add("created_at", createdAt.getEpochSecond()) + .add("text", text); + if(getReplyLevel()>0) + jb.add("replyKey", Base64.getEncoder().withoutPadding().encodeToString(Utils.serializeIntList(replyKey))); + if(attachments!=null && !attachments.isEmpty()) + jb.add("attachments", ReportableContentObject.serializeMediaAttachments(attachments, outFileIDs)); + if(poll!=null){ + jb.add("pollQuestion", poll.question); + JsonArrayBuilder jab=new JsonArrayBuilder(); + for(PollOption opt:poll.options){ + jab.add(opt.text); + } + jb.add("pollOptions", jab.build()); + } + if(hasContentWarning()) + jb.add("cw", contentWarning); + return jb.build(); + } + + @Override + public void fillFromReport(JsonObject jo){ + id=jo.get("id").getAsInt(); + ownerID=jo.get("owner").getAsInt(); + authorID=jo.get("author").getAsInt(); + createdAt=Instant.ofEpochSecond(jo.get("created_at").getAsLong()); + text=jo.get("text").getAsString(); + if(jo.has("replyKey")){ + replyKey=Utils.deserializeIntList(Base64.getDecoder().decode(jo.get("replyKey").getAsString())); + } + if(jo.has("attachments")){ + attachments=new ArrayList<>(); + for(JsonElement jatt:jo.getAsJsonArray("attachments")){ + attachments.add(ActivityPubObject.parse(jatt.getAsJsonObject(), ParserContext.LOCAL)); + } + } + if(jo.has("pollQuestion")){ + poll=new Poll(); + poll.question=jo.get("pollQuestion").getAsString(); + poll.options=new ArrayList<>(); + for(JsonElement jopt:jo.getAsJsonArray("pollOptions")){ + PollOption opt=new PollOption(); + opt.text=jopt.getAsString(); + poll.options.add(opt); + } + } + if(jo.has("cw")) + contentWarning=jo.get("cw").getAsString(); + } + public enum Privacy{ PUBLIC(null), FOLLOWERS_AND_MENTIONED("post_visible_to_followers_mentioned"), diff --git a/src/main/java/smithereen/model/ReportableContentObject.java b/src/main/java/smithereen/model/ReportableContentObject.java new file mode 100644 index 00000000..db4e343a --- /dev/null +++ b/src/main/java/smithereen/model/ReportableContentObject.java @@ -0,0 +1,28 @@ +package smithereen.model; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Set; + +import smithereen.activitypub.objects.ActivityPubObject; +import smithereen.activitypub.objects.LocalImage; +import smithereen.storage.MediaStorageUtils; +import smithereen.util.JsonArrayBuilder; + +public sealed interface ReportableContentObject permits Post, MailMessage{ + JsonObject serializeForReport(int targetID, Set outFileIDs); + void fillFromReport(JsonObject jo); + + static JsonArray serializeMediaAttachments(List attachments, Set outFileIDs){ + JsonArrayBuilder jb=new JsonArrayBuilder(); + for(ActivityPubObject att:attachments){ + jb.add(MediaStorageUtils.serializeAttachment(att)); + if(att instanceof LocalImage li){ + outFileIDs.add(li.fileID); + } + } + return jb.build(); + } +} diff --git a/src/main/java/smithereen/model/SessionInfo.java b/src/main/java/smithereen/model/SessionInfo.java index dfa533c1..5ed8d1c2 100644 --- a/src/main/java/smithereen/model/SessionInfo.java +++ b/src/main/java/smithereen/model/SessionInfo.java @@ -1,5 +1,6 @@ package smithereen.model; +import java.net.InetAddress; import java.time.ZoneId; import java.util.ArrayList; import java.util.Locale; @@ -14,6 +15,8 @@ public class SessionInfo{ public ZoneId timeZone; public ArrayList postDraftAttachments=new ArrayList<>(); public UserPermissions permissions; + public long userAgentHash; + public InetAddress ip; public static class PageHistory{ public ArrayList entries=new ArrayList<>(); diff --git a/src/main/java/smithereen/model/SignupInvitation.java b/src/main/java/smithereen/model/SignupInvitation.java index f4859dfb..8190b058 100644 --- a/src/main/java/smithereen/model/SignupInvitation.java +++ b/src/main/java/smithereen/model/SignupInvitation.java @@ -57,21 +57,5 @@ public static String getExtra(boolean noAddFriend, String firstName, String last return Utils.gson.toJson(new ExtraInfo(noAddFriend, firstName, lastName, fromRequest)); } - private static class ExtraInfo{ - public boolean noAddFriend; - public String firstName; - public String lastName; - public boolean fromRequest; - - public ExtraInfo(){ - - } - - public ExtraInfo(boolean noAddFriend, String firstName, String lastName, boolean fromRequest){ - this.noAddFriend=noAddFriend; - this.firstName=firstName; - this.lastName=lastName; - this.fromRequest=fromRequest; - } - } + private record ExtraInfo(boolean noAddFriend, String firstName, String lastName, boolean fromRequest){} } diff --git a/src/main/java/smithereen/model/User.java b/src/main/java/smithereen/model/User.java index bd9bf349..bf45cb5b 100644 --- a/src/main/java/smithereen/model/User.java +++ b/src/main/java/smithereen/model/User.java @@ -12,6 +12,7 @@ import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -38,11 +39,13 @@ public class User extends Actor{ public LocalDate birthDate; public Gender gender; public long flags; - public Map privacySettings=Map.of(); + public Map privacySettings=new HashMap<>(); public int movedTo; public int movedFrom; public Instant movedAt; public Set alsoKnownAs=new HashSet<>(); + public UserBanStatus banStatus=UserBanStatus.NONE; + public UserBanInfo banInfo; // additional profile fields public boolean manuallyApprovesFollowers; @@ -172,6 +175,13 @@ protected void fillFromResultSet(ResultSet res) throws SQLException{ if(StringUtils.isNotEmpty(privacy)){ privacySettings=Utils.gson.fromJson(privacy, new TypeToken<>(){}); } + banStatus=UserBanStatus.values()[res.getInt("ban_status")]; + if(banStatus!=UserBanStatus.NONE){ + String _banInfo=res.getString("ban_info"); + if(StringUtils.isNotEmpty(_banInfo)){ + banInfo=Utils.gson.fromJson(_banInfo, UserBanInfo.class); + } + } } @Override @@ -387,6 +397,7 @@ public void copyLocalFields(User previous){ movedTo=previous.movedTo; movedFrom=previous.movedFrom; movedAt=previous.movedAt; + banStatus=previous.banStatus; } public enum Gender{ diff --git a/src/main/java/smithereen/model/UserBanInfo.java b/src/main/java/smithereen/model/UserBanInfo.java new file mode 100644 index 00000000..f55876fd --- /dev/null +++ b/src/main/java/smithereen/model/UserBanInfo.java @@ -0,0 +1,9 @@ +package smithereen.model; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +public record UserBanInfo(@NotNull Instant bannedAt, @Nullable Instant expiresAt, @Nullable String message, boolean requirePasswordChange, int moderatorID, int reportID){ +} diff --git a/src/main/java/smithereen/model/UserBanStatus.java b/src/main/java/smithereen/model/UserBanStatus.java new file mode 100644 index 00000000..91bd2d42 --- /dev/null +++ b/src/main/java/smithereen/model/UserBanStatus.java @@ -0,0 +1,9 @@ +package smithereen.model; + +public enum UserBanStatus{ + NONE, + FROZEN, + SUSPENDED, + HIDDEN, + SELF_DEACTIVATED +} diff --git a/src/main/java/smithereen/model/UserPermissions.java b/src/main/java/smithereen/model/UserPermissions.java index 3506c0f5..86b1b127 100644 --- a/src/main/java/smithereen/model/UserPermissions.java +++ b/src/main/java/smithereen/model/UserPermissions.java @@ -1,6 +1,11 @@ package smithereen.model; +import java.util.EnumSet; import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; + +import smithereen.Config; /** * A "helper" kind of object passed around everywhere to help determine what a user @@ -9,18 +14,17 @@ public class UserPermissions{ public int userID; public HashMap managedGroups=new HashMap<>(); - public Account.AccessLevel serverAccessLevel; public boolean canInviteNewUsers; + public UserRole role; public UserPermissions(Account account){ userID=account.user.id; - serverAccessLevel=account.accessLevel; + if(account.roleID>0){ + role=Config.userRoles.get(account.roleID); + } } public boolean canDeletePost(Post post){ - // Moderators can delete any local posts - if(post.isLocal() && serverAccessLevel.ordinal()>=Account.AccessLevel.MODERATOR.ordinal()) - return true; // Users can always delete their own posts if(post.authorID==userID) return true; @@ -58,4 +62,28 @@ public boolean canReport(Object obj){ return false; } } + + public boolean hasPermission(UserRole.Permission permission){ + return role!=null && (role.permissions().contains(permission) || role.permissions().contains(UserRole.Permission.SUPERUSER)); + } + + public boolean hasAnyPermission(EnumSet permissions){ + if(role==null || permissions.isEmpty()) + return false; + if(role.permissions().contains(UserRole.Permission.SUPERUSER)) + return true; + for(UserRole.Permission perm:permissions){ + if(role.permissions().contains(perm)) + return true; + } + return false; + } + + public boolean hasPermission(String permission){ + return hasPermission(UserRole.Permission.valueOf(permission)); + } + + public boolean hasAnyPermission(List permissions){ + return hasAnyPermission(permissions.stream().map(UserRole.Permission::valueOf).collect(Collectors.toCollection(()->EnumSet.noneOf(UserRole.Permission.class)))); + } } diff --git a/src/main/java/smithereen/model/UserRole.java b/src/main/java/smithereen/model/UserRole.java new file mode 100644 index 00000000..92088a4f --- /dev/null +++ b/src/main/java/smithereen/model/UserRole.java @@ -0,0 +1,89 @@ +package smithereen.model; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.EnumSet; + +import smithereen.Utils; + +public record UserRole(int id, String name, EnumSet permissions){ + + public static UserRole fromResultSet(ResultSet res) throws SQLException{ + EnumSet permissions=EnumSet.noneOf(Permission.class); + Utils.deserializeEnumSet(permissions, Permission.class, res.getBytes("permissions")); + return new UserRole(res.getInt("id"), res.getString("name"), permissions); + } + + public String getLangKey(){ + // Translatable names for default roles + return switch(name){ + case "Owner" -> "role_owner"; + case "Admin" -> "role_admin"; + case "Moderator" -> "role_moderator"; + default -> null; + }; + } + + public enum Permission{ + SUPERUSER, + + MANAGE_SERVER_SETTINGS, + MANAGE_SERVER_RULES, + MANAGE_ROLES, + VIEW_SERVER_AUDIT_LOG, + MANAGE_USERS, + MANAGE_USER_ACCESS, + MANAGE_REPORTS, + MANAGE_FEDERATION, + MANAGE_BLOCKING_RULES, + MANAGE_INVITES, + MANAGE_ANNOUNCEMENTS, + DELETE_USERS_IMMEDIATE, + MANAGE_GROUPS, + VISIBLE_IN_STAFF; + + public String getLangKey(){ + return switch(this){ + case SUPERUSER -> "admin_permission_superuser"; + case MANAGE_SERVER_SETTINGS -> "admin_permission_server_settings"; + case MANAGE_SERVER_RULES -> "admin_permission_rules"; + case MANAGE_ROLES -> "admin_permission_roles"; + case VIEW_SERVER_AUDIT_LOG -> "admin_permission_audit_log"; + case MANAGE_USERS -> "admin_permission_manage_users"; + case MANAGE_USER_ACCESS -> "admin_permission_user_access"; + case MANAGE_REPORTS -> "admin_permission_reports"; + case MANAGE_FEDERATION -> "admin_permission_federation"; + case MANAGE_BLOCKING_RULES -> "admin_permission_blocking_rules"; + case MANAGE_INVITES -> "admin_permission_invites"; + case MANAGE_ANNOUNCEMENTS -> "admin_permission_announcements"; + case DELETE_USERS_IMMEDIATE -> "admin_permission_delete_users"; + case MANAGE_GROUPS -> "admin_permission_manage_groups"; + case VISIBLE_IN_STAFF -> "admin_visible_in_staff"; + }; + } + + public String getDescriptionLangKey(){ + return switch(this){ + case SUPERUSER -> "admin_permission_descr_superuser"; + case MANAGE_SERVER_SETTINGS -> "admin_permission_descr_server_settings"; + case MANAGE_SERVER_RULES -> "admin_permission_descr_rules"; + case MANAGE_ROLES -> "admin_permission_descr_roles"; + case VIEW_SERVER_AUDIT_LOG -> "admin_permission_descr_audit_log"; + case MANAGE_USERS -> "admin_permission_descr_manage_users"; + case MANAGE_USER_ACCESS -> "admin_permission_descr_user_access"; + case MANAGE_REPORTS -> "admin_permission_descr_reports"; + case MANAGE_FEDERATION -> "admin_permission_descr_federation"; + case MANAGE_BLOCKING_RULES -> "admin_permission_descr_blocking_rules"; + case MANAGE_INVITES -> "admin_permission_descr_invites"; + case MANAGE_ANNOUNCEMENTS -> "admin_permission_descr_announcements"; + case DELETE_USERS_IMMEDIATE -> "admin_permission_descr_delete_users"; + case MANAGE_GROUPS -> "admin_permission_descr_manage_groups"; + case VISIBLE_IN_STAFF -> "admin_visible_in_staff_descr"; + }; + } + + public boolean isActuallySetting(){ + return this==VISIBLE_IN_STAFF; + } + } +} diff --git a/src/main/java/smithereen/model/ViolationReport.java b/src/main/java/smithereen/model/ViolationReport.java index aa3d2fe4..4ccd52c1 100644 --- a/src/main/java/smithereen/model/ViolationReport.java +++ b/src/main/java/smithereen/model/ViolationReport.java @@ -1,54 +1,66 @@ package smithereen.model; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import smithereen.storage.DatabaseUtils; -import smithereen.util.XTEA; +import spark.utils.StringUtils; public class ViolationReport{ public int id; public int reporterID; - public TargetType targetType; - public ContentType contentType; public int targetID; - public long contentID; public String comment; public int moderatorID; public Instant time; - public Instant actionTime; public String serverDomain; + public State state; + public List content=List.of(); public static ViolationReport fromResultSet(ResultSet res) throws SQLException{ ViolationReport r=new ViolationReport(); r.id=res.getInt("id"); r.reporterID=res.getInt("reporter_id"); - r.targetType=TargetType.values()[res.getInt("target_type")]; r.targetID=res.getInt("target_id"); - int contentType=res.getInt("content_type"); - if(!res.wasNull()){ - r.contentType=ContentType.values()[contentType]; - r.contentID=res.getLong("content_id"); - if(r.contentType==ContentType.MESSAGE) - r.contentID=XTEA.obfuscateObjectID(r.contentID, ObfuscatedObjectIDType.MAIL_MESSAGE); - } r.comment=res.getString("comment"); r.moderatorID=res.getInt("moderator_id"); r.time=DatabaseUtils.getInstant(res, "time"); - r.actionTime=DatabaseUtils.getInstant(res, "action_time"); r.serverDomain=res.getString("server_domain"); + r.state=State.values()[res.getInt("state")]; + String content=res.getString("content"); + if(StringUtils.isNotEmpty(content)){ + JsonArray ja=JsonParser.parseString(content).getAsJsonArray(); + r.content=new ArrayList<>(); + for(JsonElement e:ja){ + r.content.add(deserializeContentObject(e.getAsJsonObject())); + } + } return r; } - public enum ContentType{ - POST, - MESSAGE + private static ReportableContentObject deserializeContentObject(JsonObject jo){ + String type=jo.get("type").getAsString(); + ReportableContentObject obj=switch(type){ + case "post" -> new Post(); + case "message" -> new MailMessage(); + default -> throw new IllegalStateException("Unexpected value: " + type); + }; + obj.fillFromReport(jo); + return obj; } - public enum TargetType{ - USER, - GROUP + public enum State{ + OPEN, + CLOSED_REJECTED, + CLOSED_ACTION_TAKEN } } diff --git a/src/main/java/smithereen/model/ViolationReportAction.java b/src/main/java/smithereen/model/ViolationReportAction.java new file mode 100644 index 00000000..6b3b6d67 --- /dev/null +++ b/src/main/java/smithereen/model/ViolationReportAction.java @@ -0,0 +1,36 @@ +package smithereen.model; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.storage.DatabaseUtils; + +public record ViolationReportAction(int id, int reportID, int userID, ActionType actionType, String text, Instant time, JsonObject extra){ + + public static ViolationReportAction fromResultSet(ResultSet res) throws SQLException{ + String extra=res.getString("extra"); + return new ViolationReportAction( + res.getInt("id"), + res.getInt("report_id"), + res.getInt("user_id"), + ActionType.values()[res.getInt("action_type")], + res.getString("text"), + DatabaseUtils.getInstant(res, "time"), + extra==null ? null : JsonParser.parseString(extra).getAsJsonObject() + ); + } + + public enum ActionType{ + COMMENT, + ADD_CONTENT, + REMOVE_CONTENT, + DELETE_CONTENT, + RESOLVE_WITH_ACTION, + RESOLVE_REJECT, + REOPEN, + } +} diff --git a/src/main/java/smithereen/model/WebDeltaResponse.java b/src/main/java/smithereen/model/WebDeltaResponse.java index ffcff27e..12fa6664 100644 --- a/src/main/java/smithereen/model/WebDeltaResponse.java +++ b/src/main/java/smithereen/model/WebDeltaResponse.java @@ -12,7 +12,7 @@ import spark.Response; public class WebDeltaResponse{ - private ArrayList commands=new ArrayList<>(); + private final ArrayList commands=new ArrayList<>(); public WebDeltaResponse(){ @@ -37,6 +37,11 @@ public WebDeltaResponse messageBox(@NotNull String title, @NotNull String msg, @ return this; } + public WebDeltaResponse confirmBox(@NotNull String title, @NotNull String msg, @NotNull String formAction){ + commands.add(new ConfirmBoxCommand(title, msg, formAction)); + return this; + } + public WebDeltaResponse box(@NotNull String title, @NotNull String content, @Nullable String id, boolean scrollable){ commands.add(new BoxCommand(title, content, id, scrollable, null)); return this; @@ -112,6 +117,11 @@ public WebDeltaResponse showSnackbar(String text){ return this; } + public WebDeltaResponse setURL(String url){ + commands.add(new SetURLCommand(url)); + return this; + } + public String json(){ return Utils.gson.toJson(commands); } @@ -184,6 +194,22 @@ public MessageBoxCommand(String title, String message, String button){ } } + private static class ConfirmBoxCommand extends Command{ + @SerializedName("t") + public String title; + @SerializedName("m") + public String message; + @SerializedName("fa") + public String formAction; + + public ConfirmBoxCommand(String title, String message, String formAction){ + super("confirmBox"); + this.title=title; + this.message=message; + this.formAction=formAction; + } + } + public static class BoxCommand extends Command{ @SerializedName("t") public String title; @@ -335,4 +361,13 @@ public ShowSnackbarCommand(String text){ this.text=text; } } + + public static class SetURLCommand extends Command{ + public String url; + + public SetURLCommand(String url){ + super("setURL"); + this.url=url; + } + } } diff --git a/src/main/java/smithereen/model/media/ImageMetadata.java b/src/main/java/smithereen/model/media/ImageMetadata.java new file mode 100644 index 00000000..7fcdee17 --- /dev/null +++ b/src/main/java/smithereen/model/media/ImageMetadata.java @@ -0,0 +1,8 @@ +package smithereen.model.media; + +public record ImageMetadata(int width, int height, String blurhash, float[] cropRegion) implements MediaFileMetadata{ + @Override + public int duration(){ + return 0; + } +} diff --git a/src/main/java/smithereen/model/media/MediaFileID.java b/src/main/java/smithereen/model/media/MediaFileID.java new file mode 100644 index 00000000..c1b7be64 --- /dev/null +++ b/src/main/java/smithereen/model/media/MediaFileID.java @@ -0,0 +1,14 @@ +package smithereen.model.media; + +import java.util.Base64; + +import smithereen.Utils; +import smithereen.model.ObfuscatedObjectIDType; +import smithereen.util.XTEA; + +public record MediaFileID(long id, byte[] randomID, int originalOwnerID, MediaFileType type){ + public String getIDForClient(){ + return Base64.getUrlEncoder().withoutPadding().encodeToString(Utils.packLong(XTEA.obfuscateObjectID(id, ObfuscatedObjectIDType.MEDIA_FILE))) + +":"+Base64.getUrlEncoder().withoutPadding().encodeToString(randomID); + } +} diff --git a/src/main/java/smithereen/model/media/MediaFileMetadata.java b/src/main/java/smithereen/model/media/MediaFileMetadata.java new file mode 100644 index 00000000..a01270dc --- /dev/null +++ b/src/main/java/smithereen/model/media/MediaFileMetadata.java @@ -0,0 +1,15 @@ +package smithereen.model.media; + +public interface MediaFileMetadata{ + int width(); + int height(); + String blurhash(); + float[] cropRegion(); + int duration(); + + static Class classForType(MediaFileType type){ + return switch(type){ + case IMAGE_PHOTO, IMAGE_AVATAR, IMAGE_GRAFFITI -> ImageMetadata.class; + }; + } +} diff --git a/src/main/java/smithereen/model/media/MediaFileRecord.java b/src/main/java/smithereen/model/media/MediaFileRecord.java new file mode 100644 index 00000000..9fe61138 --- /dev/null +++ b/src/main/java/smithereen/model/media/MediaFileRecord.java @@ -0,0 +1,20 @@ +package smithereen.model.media; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.Utils; +import smithereen.storage.DatabaseUtils; + +public record MediaFileRecord(MediaFileID id, long size, Instant createdAt, MediaFileMetadata metadata){ + public static MediaFileRecord fromResultSet(ResultSet res) throws SQLException{ + MediaFileType type=MediaFileType.values()[res.getInt("type")]; + return new MediaFileRecord( + new MediaFileID(res.getLong("id"), res.getBytes("random_id"), res.getInt("original_owner_id"), type), + res.getLong("size"), + DatabaseUtils.getInstant(res, "created_at"), + Utils.gson.fromJson(res.getString("metadata"), MediaFileMetadata.classForType(type)) + ); + } +} diff --git a/src/main/java/smithereen/model/media/MediaFileReferenceType.java b/src/main/java/smithereen/model/media/MediaFileReferenceType.java new file mode 100644 index 00000000..1b8fc312 --- /dev/null +++ b/src/main/java/smithereen/model/media/MediaFileReferenceType.java @@ -0,0 +1,9 @@ +package smithereen.model.media; + +public enum MediaFileReferenceType{ + USER_AVATAR, + GROUP_AVATAR, + WALL_ATTACHMENT, + MAIL_ATTACHMENT, + REPORT_OBJECT +} diff --git a/src/main/java/smithereen/model/media/MediaFileType.java b/src/main/java/smithereen/model/media/MediaFileType.java new file mode 100644 index 00000000..030cbed2 --- /dev/null +++ b/src/main/java/smithereen/model/media/MediaFileType.java @@ -0,0 +1,15 @@ +package smithereen.model.media; + +public enum MediaFileType{ + IMAGE_PHOTO, + IMAGE_AVATAR, + IMAGE_GRAFFITI; + + public String getFileExtension(){ + return "webp"; + } + + public String getMimeType(){ + return "image/webp"; + } +} diff --git a/src/main/java/smithereen/model/viewmodel/AdminUserViewModel.java b/src/main/java/smithereen/model/viewmodel/AdminUserViewModel.java new file mode 100644 index 00000000..d052c62d --- /dev/null +++ b/src/main/java/smithereen/model/viewmodel/AdminUserViewModel.java @@ -0,0 +1,35 @@ +package smithereen.model.viewmodel; + +import java.net.InetAddress; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +import smithereen.Utils; +import smithereen.model.Account; +import smithereen.storage.DatabaseUtils; + +public record AdminUserViewModel(int userID, int accountID, int role, String email, Account.ActivationInfo activationInfo, Instant lastActive, InetAddress lastIP){ +// public record Counters(int posts, int comments, int friends, int followers, int reportsFrom, int reportsOf, int staffComments, int strikes){} + public static AdminUserViewModel fromResultSet(ResultSet res) throws SQLException{ + String activationInfo=res.getString("activation_info"); + return new AdminUserViewModel( + res.getInt("user_id"), + res.getInt("account_id"), + res.getInt("role"), + res.getString("email"), + activationInfo!=null ? Utils.gson.fromJson(activationInfo, Account.ActivationInfo.class) : null, + DatabaseUtils.getInstant(res, "last_active"), + Utils.deserializeInetAddress(res.getBytes("last_ip")) + ); + } + + public String getEmailDomain(){ + if(email==null) + return null; + int index=email.indexOf('@'); + if(index==-1) + return null; + return email.substring(index+1); + } +} diff --git a/src/main/java/smithereen/model/viewmodel/AuditLogEntryViewModel.java b/src/main/java/smithereen/model/viewmodel/AuditLogEntryViewModel.java new file mode 100644 index 00000000..aa7dba05 --- /dev/null +++ b/src/main/java/smithereen/model/viewmodel/AuditLogEntryViewModel.java @@ -0,0 +1,6 @@ +package smithereen.model.viewmodel; + +import smithereen.model.AuditLogEntry; + +public record AuditLogEntryViewModel(AuditLogEntry entry, String mainTextHtml, String extraTextHtml){ +} diff --git a/src/main/java/smithereen/model/viewmodel/UserContentMetrics.java b/src/main/java/smithereen/model/viewmodel/UserContentMetrics.java new file mode 100644 index 00000000..9d7209e3 --- /dev/null +++ b/src/main/java/smithereen/model/viewmodel/UserContentMetrics.java @@ -0,0 +1,4 @@ +package smithereen.model.viewmodel; + +public record UserContentMetrics(int postCount, int commentCount){ +} diff --git a/src/main/java/smithereen/model/viewmodel/UserRelationshipMetrics.java b/src/main/java/smithereen/model/viewmodel/UserRelationshipMetrics.java new file mode 100644 index 00000000..491aaf31 --- /dev/null +++ b/src/main/java/smithereen/model/viewmodel/UserRelationshipMetrics.java @@ -0,0 +1,4 @@ +package smithereen.model.viewmodel; + +public record UserRelationshipMetrics(int friends, int followers, int following){ +} diff --git a/src/main/java/smithereen/model/viewmodel/UserRoleViewModel.java b/src/main/java/smithereen/model/viewmodel/UserRoleViewModel.java new file mode 100644 index 00000000..ba5f32fe --- /dev/null +++ b/src/main/java/smithereen/model/viewmodel/UserRoleViewModel.java @@ -0,0 +1,6 @@ +package smithereen.model.viewmodel; + +import smithereen.model.UserRole; + +public record UserRoleViewModel(UserRole role, int numUsers, boolean canEdit){ +} diff --git a/src/main/java/smithereen/model/viewmodel/ViolationReportActionViewModel.java b/src/main/java/smithereen/model/viewmodel/ViolationReportActionViewModel.java new file mode 100644 index 00000000..15069369 --- /dev/null +++ b/src/main/java/smithereen/model/viewmodel/ViolationReportActionViewModel.java @@ -0,0 +1,6 @@ +package smithereen.model.viewmodel; + +import smithereen.model.ViolationReportAction; + +public record ViolationReportActionViewModel(ViolationReportAction action, String mainTextHtml, String extraTextHtml){ +} diff --git a/src/main/java/smithereen/routes/ActivityPubRoutes.java b/src/main/java/smithereen/routes/ActivityPubRoutes.java index dfae9b25..2b56b340 100644 --- a/src/main/java/smithereen/routes/ActivityPubRoutes.java +++ b/src/main/java/smithereen/routes/ActivityPubRoutes.java @@ -121,13 +121,14 @@ import smithereen.model.Post; import smithereen.model.Server; import smithereen.model.StatsType; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.exceptions.BadRequestException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.exceptions.UserActionNotAllowedException; import smithereen.jsonld.JLDProcessor; import smithereen.jsonld.LinkedDataSignatures; +import smithereen.model.UserBanStatus; import smithereen.sparkext.ActivityPubCollectionPageResponse; import smithereen.storage.GroupStorage; import smithereen.storage.LikeStorage; @@ -474,10 +475,11 @@ public static Object externalInteraction(Request req, Response resp, Account sel // ?type=favourite // ?type=reply // user/remote_follow + requireQueryParams(req, "uri"); String ref=req.headers("referer"); ActivityPubObject remoteObj; try{ - URI uri=new URI(req.queryParams("uri")); + URI uri=UriBuilder.parseAndEncode(req.queryParams("uri")); if(!"https".equals(uri.getScheme()) && !(Config.useHTTP && "http".equals(uri.getScheme()))){ // try parsing as "username@domain" or "acct:username@domain" String rawUri=uri.getSchemeSpecificPart(); @@ -496,6 +498,7 @@ public static Object externalInteraction(Request req, Response resp, Account sel return "Object ID host doesn't match URI host"; } }catch(IOException|JsonParseException|URISyntaxException x){ + LOG.debug("Error fetching remote object", x); return x.getMessage(); } if(remoteObj instanceof ForeignUser foreignUser){ @@ -664,8 +667,13 @@ private static Object inbox(Request req, Response resp, Actor owner) throws SQLE } } String body=req.body(); - LOG.info("Incoming activity: {}", body); - JsonObject rawActivity=JsonParser.parseString(body).getAsJsonObject(); + LOG.debug("Incoming activity: {}", body); + JsonObject rawActivity; + try{ + rawActivity=JsonParser.parseString(body).getAsJsonObject(); + }catch(Exception x){ + throw new BadRequestException("Failed to parse request body as JSON", x); + } JsonObject obj=JLDProcessor.convertToLocalContext(rawActivity); Activity activity; @@ -711,11 +719,15 @@ else if(o!=null) } if(!(actor instanceof ForeignActor fa)) throw new BadRequestException("Actor is local"); + if(actor instanceof ForeignUser fu && fu.banStatus==UserBanStatus.SUSPENDED){ + resp.status(403); + return "This actor is suspended from this server"; + } if(fa.needUpdate() && canUpdate){ try{ actor=ctx.getObjectLinkResolver().resolve(activity.actor.link, Actor.class, true, true, true); }catch(ObjectNotFoundException x){ - LOG.warn("Exception while refreshing remote actor", x); + LOG.warn("Exception while refreshing remote actor {}", activity.actor.link, x); } } @@ -723,7 +735,7 @@ else if(o!=null) try{ httpSigOwner=ActivityPub.verifyHttpSignature(req, actor); }catch(Exception x){ - LOG.warn("Exception while verifying HTTP signature", x); + LOG.debug("Exception while verifying HTTP signature", x); throw new UserActionNotAllowedException(x); } @@ -741,17 +753,17 @@ else if(o!=null) if(!LinkedDataSignatures.verify(rawActivity, actor.publicKey)){ throw new BadRequestException("LD-signature verification failed"); } - LOG.info("verified LD signature by {}", userID); + LOG.debug("verified LD signature by {}", userID); hasValidLDSignature=true; }catch(Exception x){ - LOG.info("Exception while verifying LD-signature", x); + LOG.debug("Exception while verifying LD-signature", x); } } if(!hasValidLDSignature){ if(!actor.equals(httpSigOwner)){ throw new BadRequestException("In the absence of a valid LD-signature, HTTP signature must be made by the activity actor"); } - LOG.info("verified HTTP signature by {}", httpSigOwner.activityPubID); + LOG.debug("verified HTTP signature by {}", httpSigOwner.activityPubID); } // parse again to make sure the actor is set everywhere try{ @@ -775,6 +787,14 @@ else if(o!=null) return ""; } } + if(activity.object==null){ + // Something unsupported that doesn't have an object/link + if(Config.DEBUG) + throw new BadRequestException("No handler found for activity type: "+getActivityType(activity)); + else + LOG.error("Received and ignored an activity of an unsupported type {}", getActivityType(activity)); + return ""; + } // Match more thoroughly ActivityPubObject aobj; @@ -785,7 +805,7 @@ else if(o!=null) try{ aobj=ctx.getObjectLinkResolver().resolve(aobj.activityPubID); }catch(ObjectNotFoundException x){ - LOG.warn("Activity object not found for {}: {}", getActivityType(activity), aobj.activityPubID); + LOG.debug("Activity object not found for {}: {}", getActivityType(activity), aobj.activityPubID); // Fail silently. We didn't have that object anyway, there's nothing to delete. return ""; } @@ -796,7 +816,7 @@ else if(o!=null) aobj=ctx.getObjectLinkResolver().resolve(activity.object.link, ActivityPubObject.class, false, false, false); }catch(ObjectNotFoundException x){ // Fail silently. Pleroma sends all likes to followers, including for objects they may not have. - LOG.info("Activity object not known for {}: {}", activity.getType(), activity.object.link); + LOG.debug("Activity object not known for {}: {}", activity.getType(), activity.object.link); return ""; } }else{ @@ -828,17 +848,17 @@ else if(o!=null) doublyNestedObject=ctx.getObjectLinkResolver().resolve(nestedActivity.object.link); if(r.objectClass.isInstance(doublyNestedObject)){ - LOG.info("Found match: {}", r.handler.getClass().getName()); + LOG.debug("Found match: {}", r.handler.getClass().getName()); ((DoublyNestedActivityTypeHandler)r.handler).handle(context, actor, activity, nestedActivity, doublyNestedActivity, doublyNestedObject); return ""; } }else if(r.objectClass.isInstance(nestedObject)){ - LOG.info("Found match: {}", r.handler.getClass().getName()); + LOG.debug("Found match: {}", r.handler.getClass().getName()); ((NestedActivityTypeHandler)r.handler).handle(context, actor, activity, nestedActivity, nestedObject); return ""; } }else if(r.objectClass.isInstance(aobj)){ - LOG.info("Found match: {}", r.handler.getClass().getName()); + LOG.debug("Found match: {}", r.handler.getClass().getName()); r.handler.handle(context, actor, activity, aobj); return ""; } @@ -852,7 +872,7 @@ else if(o!=null) resp.status(403); return escapeHTML(x.getMessage()); }catch(BadRequestException x){ - LOG.warn("Bad request", x); + LOG.debug("Bad request", x); resp.status(400); return escapeHTML(x.getMessage()); }/*catch(Exception x){ @@ -868,7 +888,7 @@ else if(o!=null) private static String getActivityType(ActivityPubObject obj){ String r=obj.getType(); - if(obj instanceof Activity a){ + if(obj instanceof Activity a && a.object!=null){ r+="{"; if(a.object.object!=null){ r+=getActivityType(a.object.object); diff --git a/src/main/java/smithereen/routes/FriendsRoutes.java b/src/main/java/smithereen/routes/FriendsRoutes.java index 8eeb063d..cf7ca612 100644 --- a/src/main/java/smithereen/routes/FriendsRoutes.java +++ b/src/main/java/smithereen/routes/FriendsRoutes.java @@ -11,6 +11,7 @@ import smithereen.Utils; import smithereen.controllers.FriendsController; import smithereen.model.Account; +import smithereen.model.ForeignUser; import smithereen.model.FriendshipStatus; import smithereen.model.Group; import smithereen.model.PaginatedList; @@ -35,6 +36,7 @@ public static Object confirmSendFriendRequest(Request req, Response resp, Accoun return wrapError(req, resp, "err_cant_friend_self"); } ctx.getUsersController().ensureUserNotBlocked(self.user, user); + ctx.getPrivacyController().enforceUserProfileAccess(self.user, user); FriendshipStatus status=ctx.getFriendsController().getFriendshipStatus(self.user, user); Lang l=lang(req); switch(status){ @@ -67,6 +69,7 @@ public static Object confirmSendFriendRequest(Request req, Response resp, Accoun public static Object doSendFriendRequest(Request req, Response resp, Account self, ApplicationContext ctx){ User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + ctx.getPrivacyController().enforceUserProfileAccess(self.user, user); ctx.getFriendsController().sendFriendRequest(self.user, user, req.queryParams("message")); if(isAjax(req)){ return new WebDeltaResponse(resp).refresh(); @@ -89,6 +92,7 @@ public static Object confirmRemoveFriend(Request req, Response resp, Account sel } private static Object friends(Request req, Response resp, User user, Account self, ApplicationContext ctx){ + ctx.getPrivacyController().enforceUserProfileAccess(self!=null ? self.user : null, user); RenderedTemplateResponse model=new RenderedTemplateResponse("friends", req); model.with("owner", user); model.pageTitle(lang(req).get("friends")); @@ -114,6 +118,8 @@ private static Object friends(Request req, Response resp, User user, Account sel model.addNavBarItem(group.name, group.getProfileURL()).addNavBarItem(lang(req).get("invite_friends_title")); model.pageTitle(lang(req).get("invite_friends_title")); } + if(user instanceof ForeignUser) + model.with("noindex", true); jsLangKey(req, "remove_friend", "yes", "no"); return model; } @@ -133,6 +139,7 @@ public static Object mutualFriends(Request req, Response resp, Account self, App User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); if(user.id==self.user.id) throw new ObjectNotFoundException("err_user_not_found"); + ctx.getPrivacyController().enforceUserProfileAccess(self.user, user); RenderedTemplateResponse model=new RenderedTemplateResponse("friends", req); PaginatedList friends=ctx.getFriendsController().getMutualFriends(user, self.user, offset(req), 100, FriendsController.SortOrder.ID_ASCENDING); model.paginate(friends); @@ -140,6 +147,8 @@ public static Object mutualFriends(Request req, Response resp, Account self, App model.pageTitle(lang(req).get("friends")); model.with("tab", "mutual"); model.with("mutualCount", friends.total); + if(user instanceof ForeignUser) + model.with("noindex", true); jsLangKey(req, "remove_friend", "yes", "no"); return model; } @@ -159,6 +168,7 @@ public static Object followers(Request req, Response resp){ }else{ user=context(req).getUsersController().getUserOrThrow(safeParseInt(_id)); } + ctx.getPrivacyController().enforceUserProfileAccess(self!=null ? self.user : null, user); RenderedTemplateResponse model=new RenderedTemplateResponse("friends", req); model.with("title", lang(req).get("followers")).with("toolbarTitle", lang(req).get("friends")); model.paginate(context(req).getFriendsController().getFollowers(user, offset(req), 100)); @@ -167,6 +177,8 @@ public static Object followers(Request req, Response resp){ int mutualCount=ctx.getFriendsController().getMutualFriends(self.user, user, 0, 0, FriendsController.SortOrder.ID_ASCENDING).total; model.with("mutualCount", mutualCount); } + if(user instanceof ForeignUser) + model.with("noindex", true); return model; } @@ -186,6 +198,7 @@ public static Object following(Request req, Response resp){ }else{ user=context(req).getUsersController().getUserOrThrow(safeParseInt(_id)); } + ctx.getPrivacyController().enforceUserProfileAccess(self!=null ? self.user : null, user); RenderedTemplateResponse model=new RenderedTemplateResponse("friends", req); model.with("title", lang(req).get("following")).with("toolbarTitle", lang(req).get("friends")); model.paginate(context(req).getFriendsController().getFollows(user, offset(req), 100)); @@ -194,6 +207,8 @@ public static Object following(Request req, Response resp){ int mutualCount=ctx.getFriendsController().getMutualFriendsCount(self.user, user); model.with("mutualCount", mutualCount); } + if(user instanceof ForeignUser) + model.with("noindex", true); jsLangKey(req, "unfollow", "yes", "no"); return model; } diff --git a/src/main/java/smithereen/routes/GroupsRoutes.java b/src/main/java/smithereen/routes/GroupsRoutes.java index f4f67be3..1e29277f 100644 --- a/src/main/java/smithereen/routes/GroupsRoutes.java +++ b/src/main/java/smithereen/routes/GroupsRoutes.java @@ -77,6 +77,7 @@ public static Object userGroups(Request req, Response resp){ public static Object userGroups(Request req, Response resp, User user){ jsLangKey(req, "cancel", "create"); SessionInfo info=sessionInfo(req); + context(req).getPrivacyController().enforceUserProfileAccess(info!=null && info.account!=null ? info.account.user : null, user); RenderedTemplateResponse model=new RenderedTemplateResponse("groups", req).with("tab", "groups").with("title", lang(req).get("groups")); model.paginate(context(req).getGroupsController().getUserGroups(user, info!=null && info.account!=null ? info.account.user : null, offset(req), 100)); model.with("owner", user); @@ -363,6 +364,8 @@ private static Object members(Request req, Response resp, boolean tentative){ model.paginate(context(req).getGroupsController().getMembers(group, offset(req), 100, tentative)); model.with("summary", lang(req).get(tentative ? "summary_event_X_tentative_members" : (group.isEvent() ? "summary_event_X_members" : "summary_group_X_members"), Map.of("count", tentative ? group.tentativeMemberCount : group.memberCount))); model.with("contentTemplate", "user_grid").with("title", group.name); + if(group instanceof ForeignGroup) + model.with("noindex", true); return model; } @@ -372,6 +375,8 @@ public static Object admins(Request req, Response resp){ context(req).getPrivacyController().enforceUserAccessToGroupProfile(info!=null && info.account!=null ? info.account.user : null, group); RenderedTemplateResponse model=new RenderedTemplateResponse("actor_list", req); model.with("actors", context(req).getGroupsController().getAdmins(group).stream().map(a->new ActorWithDescription(a.user, a.title)).collect(Collectors.toList())); + if(group instanceof ForeignGroup) + model.with("noindex", true); if(isAjax(req)){ return new WebDeltaResponse(resp).box(lang(req).get(group.isEvent() ? "event_organizers" : "group_admins"), model.renderContentBlock(), null, true); } diff --git a/src/main/java/smithereen/routes/MailRoutes.java b/src/main/java/smithereen/routes/MailRoutes.java index f4be985c..aa5b5f36 100644 --- a/src/main/java/smithereen/routes/MailRoutes.java +++ b/src/main/java/smithereen/routes/MailRoutes.java @@ -89,7 +89,11 @@ public static Object viewMessage(Request req, Response resp, Account self, Appli }else{ langKey=post.getReplyLevel()>0 ? "mail_in_reply_to_comment" : "mail_in_reply_to_post"; } - model.with("inReplyToLink", lang(req).get(langKey)).with("inReplyToURL", post.getInternalURL()); + User author=null; + try{ + author=ctx.getUsersController().getUserOrThrow(post.authorID); + }catch(ObjectNotFoundException ignore){} + model.with("inReplyToLink", lang(req).get(langKey, Map.of("name", author==null ? "DELETED" : author.getFirstLastAndGender()))).with("inReplyToURL", post.getInternalURL()); }catch(ObjectNotFoundException ignore){} } } diff --git a/src/main/java/smithereen/routes/PostRoutes.java b/src/main/java/smithereen/routes/PostRoutes.java index d76559df..0c63a2a0 100644 --- a/src/main/java/smithereen/routes/PostRoutes.java +++ b/src/main/java/smithereen/routes/PostRoutes.java @@ -24,17 +24,20 @@ import smithereen.Config; import smithereen.Utils; import smithereen.activitypub.objects.Actor; +import smithereen.activitypub.objects.ForeignActor; import smithereen.model.Account; import smithereen.model.Group; import smithereen.model.PaginatedList; import smithereen.model.Poll; import smithereen.model.PollOption; import smithereen.model.Post; +import smithereen.model.ReportableContentObject; import smithereen.model.SessionInfo; import smithereen.model.SizedImage; import smithereen.model.User; import smithereen.model.UserInteractions; import smithereen.model.UserPrivacySettingKey; +import smithereen.model.UserRole; import smithereen.model.ViolationReport; import smithereen.model.WebDeltaResponse; import smithereen.model.attachments.Attachment; @@ -119,6 +122,7 @@ public static Object createWallPost(Request req, Response resp, Account self, @N model.with("topLevel", new PostViewModel(context(req).getWallController().getPostOrThrow(post.replyKey.get(0)))); } model.with("users", Map.of(self.user.id, self.user)); + model.with("posts", Map.of(post.id, post)); String postHTML=model.renderToString(); if(req.attribute("mobile")!=null && replyTo==0){ postHTML="
"+postHTML+"
"; @@ -311,14 +315,25 @@ public static Object standalonePost(Request req, Response resp){ model.paginate(replies); model.with("post", post); model.with("isGroup", post.post.ownerID<0); + int cwCount=0; + for(PostViewModel reply:replies.list){ + if(reply.post.hasContentWarning()) + cwCount++; + } + model.with("needExpandCWsButton", cwCount>1); boolean canOverridePrivacy=false; - if(self!=null && info.permissions.serverAccessLevel.ordinal()>=Account.AccessLevel.MODERATOR.ordinal()){ + if(self!=null && info.permissions.hasPermission(UserRole.Permission.MANAGE_REPORTS)){ int reportID=safeParseInt(req.queryParams("report")); if(reportID!=0){ try{ - ViolationReport report=ctx.getModerationController().getViolationReportByID(reportID); - canOverridePrivacy=report.contentType==ViolationReport.ContentType.POST && report.contentID==postID; + ViolationReport report=ctx.getModerationController().getViolationReportByID(reportID, false); + for(ReportableContentObject c:report.content){ + if(c instanceof Post p && p.id==postID){ + canOverridePrivacy=true; + break; + } + } }catch(ObjectNotFoundException ignore){} } } @@ -359,9 +374,10 @@ public static Object standalonePost(Request req, Response resp){ meta.put("og:site_name", Config.serverDisplayName); meta.put("og:type", "article"); meta.put("og:title", author.getFullName()); -// meta.put("og:url", post.url.toString()); + meta.put("og:url", post.post.getInternalURL().toString()); meta.put("og:published_time", Utils.formatDateAsISO(post.post.createdAt)); meta.put("og:author", author.url.toString()); + meta.put("profile:username", author.username+"@"+Config.domain); if(StringUtils.isNotEmpty(post.post.text)){ String text=Utils.truncateOnWordBoundary(post.post.text, 250); meta.put("og:description", text); @@ -375,19 +391,23 @@ public static Object standalonePost(Request req, Response resp){ meta.put("og:image", pa.image.getUriForSizeAndFormat(SizedImage.Type.MEDIUM, SizedImage.Format.JPEG).toString()); meta.put("og:image:width", String.valueOf(size.width)); meta.put("og:image:height", String.valueOf(size.height)); + meta.put("og:image:type", "image/jpeg"); + meta.put("twitter:card", "summary_large_image"); hasImage=true; break; } } } if(!hasImage){ + meta.put("twitter:card", "summary"); if(author.hasAvatar()){ - URI img=author.getAvatar().getUriForSizeAndFormat(SizedImage.Type.LARGE, SizedImage.Format.JPEG); + URI img=author.getAvatar().getUriForSizeAndFormat(SizedImage.Type.SQUARE_XLARGE, SizedImage.Format.JPEG); if(img!=null){ - SizedImage.Dimensions size=author.getAvatar().getDimensionsForSize(SizedImage.Type.LARGE); + SizedImage.Dimensions size=author.getAvatar().getDimensionsForSize(SizedImage.Type.SQUARE_XLARGE); meta.put("og:image", img.toString()); meta.put("og:image:width", String.valueOf(size.width)); meta.put("og:image:height", String.valueOf(size.height)); + meta.put("og:image:type", "image/jpeg"); } } } @@ -405,6 +425,8 @@ public static Object standalonePost(Request req, Response resp){ model.with("jsRedirect", "/posts/"+post.post.replyKey.get(0)+"#comment"+post.post.id); } model.with("activityPubURL", post.post.getActivityPubID()); + if(!post.post.isLocal() && owner instanceof ForeignActor) + model.with("noindex", true); return model; } @@ -581,6 +603,9 @@ private static Object wall(Request req, Response resp, Actor owner, boolean ownO model.pageTitle(lang(req).get("wall_of_group")); } + if(owner instanceof ForeignActor) + model.with("noindex", true); + return model; } @@ -694,8 +719,9 @@ public static Object pollOptionVoters(Request req, Response resp){ context(req).getPrivacyController().enforceObjectPrivacy(self!=null ? self.user : null, post); List users=ctx.getWallController().getPollOptionVoters(option, offset, 100); - RenderedTemplateResponse model=new RenderedTemplateResponse(isAjax(req) ? "user_grid" : "content_wrap", req).with("users", users); - model.with("pageOffset", offset).with("total", option.numVotes).with("paginationUrlPrefix", "/posts/"+postID+"/pollVoters/"+option.id+"?fromPagination&offset=").with("emptyMessage", lang(req).get("poll_option_votes_empty")); + RenderedTemplateResponse model=new RenderedTemplateResponse(isAjax(req) ? "user_grid" : "content_wrap", req); + model.paginate(new PaginatedList<>(users, option.numVotes, offset, 100), "/posts/"+postID+"/pollVoters/"+option.id+"?fromPagination&offset=", null); + model.with("emptyMessage", lang(req).get("poll_option_votes_empty")).with("summary", lang(req).get("X_people_voted_title", Map.of("count", option.numVotes))); if(isAjax(req)){ if(req.queryParams("fromPagination")==null) return new WebDeltaResponse(resp).box(option.text, model.renderToString(), "likesList", 610); diff --git a/src/main/java/smithereen/routes/ProfileRoutes.java b/src/main/java/smithereen/routes/ProfileRoutes.java index aeb30628..1d1efd65 100644 --- a/src/main/java/smithereen/routes/ProfileRoutes.java +++ b/src/main/java/smithereen/routes/ProfileRoutes.java @@ -26,6 +26,7 @@ import smithereen.model.SessionInfo; import smithereen.model.SizedImage; import smithereen.model.User; +import smithereen.model.UserBanStatus; import smithereen.model.UserInteractions; import smithereen.model.UserPrivacySettingKey; import smithereen.model.WebDeltaResponse; @@ -55,6 +56,7 @@ public static Object profile(Request req, Response resp){ return switch(ur.type()){ case USER -> { User user=ctx.getUsersController().getUserOrThrow(ur.localID()); + ctx.getPrivacyController().enforceUserProfileAccess(self!=null ? self.user : null, user); boolean isSelf=self!=null && self.user.id==user.id; int offset=offset(req); HashSet needUsers=new HashSet<>(), needGroups=new HashSet<>(); diff --git a/src/main/java/smithereen/routes/SessionRoutes.java b/src/main/java/smithereen/routes/SessionRoutes.java index 2f354045..41914868 100644 --- a/src/main/java/smithereen/routes/SessionRoutes.java +++ b/src/main/java/smithereen/routes/SessionRoutes.java @@ -3,7 +3,9 @@ import io.pebbletemplates.pebble.extension.escaper.SafeString; import java.sql.SQLException; +import java.time.Instant; import java.util.Base64; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; @@ -15,9 +17,11 @@ import smithereen.Utils; import smithereen.model.Account; import smithereen.model.EmailCode; +import smithereen.model.EmailDomainBlockRule; import smithereen.model.SessionInfo; import smithereen.model.SignupInvitation; import smithereen.model.User; +import smithereen.model.UserBanStatus; import smithereen.model.WebDeltaResponse; import smithereen.exceptions.BadRequestException; import smithereen.exceptions.InternalServerErrorException; @@ -28,6 +32,7 @@ import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; import smithereen.templates.RenderedTemplateResponse; +import smithereen.util.EmailCodeActionType; import smithereen.util.FloodControl; import spark.Request; import spark.Response; @@ -40,7 +45,7 @@ private static void setupSessionWithAccount(Request req, Response resp, Account SessionInfo info=new SessionInfo(); info.account=acc; req.session(true).attribute("info", info); - String psid=SessionStorage.putNewSession(req.session()); + String psid=SessionStorage.putNewSession(req.session(), Objects.requireNonNull(req.userAgent(), ""), Utils.getRequestIP(req)); info.csrfToken=Utils.csrfTokenFromSessionID(Base64.getDecoder().decode(psid)); if(acc.prefs.locale==null){ Locale requestLocale=req.raw().getLocale(); @@ -102,6 +107,7 @@ public static Object logout(Request req, Response resp) throws SQLException{ } private static RenderedTemplateResponse regError(Request req, String errKey){ + Config.SignupMode signupMode=context(req).getModerationController().getEffectiveSignupMode(req); RenderedTemplateResponse model=new RenderedTemplateResponse("register", req) .with("message", Utils.lang(req).get(errKey)) .with("username", req.queryParams("username")) @@ -112,9 +118,9 @@ private static RenderedTemplateResponse regError(Request req, String errKey){ .with("last_name", req.queryParams("last_name")) .with("invite", req.queryParams("invite")) .with("preFilledInvite", req.queryParams("_invite")) - .with("signupMode", Config.signupMode) + .with("signupMode", signupMode) .with("title", lang(req).get("register")); - if(Config.signupMode==Config.SignupMode.OPEN && Config.signupFormUseCaptcha){ + if(signupMode==Config.SignupMode.OPEN && Config.signupFormUseCaptcha){ model.with("captchaSid", randomAlphanumericString(16)); } return model; @@ -124,6 +130,14 @@ public static Object register(Request req, Response resp) throws SQLException{ if(redirectIfLoggedIn(req, resp)) return ""; + // Honeypot fields + String passwordConfirm=req.queryParams("passwordConfirm"); + String website=req.queryParams("website"); + if(StringUtils.isNotEmpty(passwordConfirm) || StringUtils.isNotEmpty(website)){ + req.session().attribute("bannedBot", true); + throw new UserActionNotAllowedException(); + } + String username=req.queryParams("username"); if(StringUtils.isEmpty(username) || !Utils.isValidUsername(username)) return regError(req, "err_reg_invalid_username"); @@ -144,6 +158,18 @@ public static Object doRegister(Request req, Response resp) throws SQLException{ if(redirectIfLoggedIn(req, resp)) return ""; + ApplicationContext ctx=context(req); + + // TODO move all this into UsersController and don't ask/assign username at signup + Config.SignupMode signupMode=ctx.getModerationController().getEffectiveSignupMode(req); + if(Config.signupFormUseCaptcha && signupMode==Config.SignupMode.OPEN){ + try{ + verifyCaptcha(req); + }catch(UserErrorException x){ + return regError(req, x.getMessage()); + } + } + String username=req.queryParams("username"); String password=req.queryParams("password"); String password2=req.queryParams("password2"); @@ -165,7 +191,7 @@ public static Object doRegister(Request req, Response resp) throws SQLException{ SignupInvitation invitation=null; if(StringUtils.isEmpty(invite)) invite=req.queryParams("_invite"); - if(Config.signupMode!=Config.SignupMode.OPEN || StringUtils.isNotEmpty(invite)){ + if(signupMode!=Config.SignupMode.OPEN || StringUtils.isNotEmpty(invite)){ if(StringUtils.isEmpty(invite) || !invite.matches("^[A-Fa-f0-9]{32}$")) return regError(req, "err_invalid_invitation"); invitation=context(req).getUsersController().getInvite(invite); @@ -173,25 +199,25 @@ public static Object doRegister(Request req, Response resp) throws SQLException{ return regError(req, "err_invalid_invitation"); } - if(Config.signupFormUseCaptcha && Config.signupMode==Config.SignupMode.OPEN){ - try{ - String captcha=requireFormField(req, "captcha", "err_wrong_captcha"); - String sid=requireFormField(req, "captchaSid", "err_wrong_captcha"); - LruCache captchas=req.session().attribute("captchas"); - if(captchas==null || !captcha.equals(captchas.remove(sid))) - throw new UserErrorException("err_wrong_captcha"); - }catch(UserErrorException x){ - return regError(req, x.getMessage()); - } - } - User.Gender gender=lang(req).detectGenderForName(first, last, null); SessionStorage.SignupResult res; - if(Config.signupMode==Config.SignupMode.OPEN && invitation==null) - res=SessionStorage.registerNewAccount(username, password, email, first, last, gender); - else + if(signupMode==Config.SignupMode.OPEN && invitation==null){ + EmailDomainBlockRule blockRule=ctx.getModerationController().matchEmailDomainBlockRule(email); + if(blockRule!=null){ + return switch(blockRule.action()){ + case BLOCK -> regError(req, "err_reg_email_domain_not_allowed"); + case MANUAL_REVIEW -> { + context(req).getUsersController().requestSignupInvite(req, first, last, email, "(Automatically sent for manual review because of an email domain rule)"); + yield new RenderedTemplateResponse("generic_message", req).with("message", lang(req).get("signup_request_submitted")); + } + }; + }else{ + res=SessionStorage.registerNewAccount(username, password, email, first, last, gender); + } + }else{ res=SessionStorage.registerNewAccount(username, password, email, first, last, gender, invite); + } if(res==SessionStorage.SignupResult.SUCCESS){ Account acc=Objects.requireNonNull(SessionStorage.getAccountForUsernameAndPassword(username, password)); if(Config.signupConfirmEmail && (invitation==null || StringUtils.isEmpty(invitation.email) || !email.equalsIgnoreCase(invitation.email))){ @@ -218,11 +244,12 @@ public static Object registerForm(Request req, Response resp){ return ""; String invite=req.queryParams("invite"); - if(Config.signupMode==Config.SignupMode.CLOSED && StringUtils.isEmpty(invite)) + Config.SignupMode signupMode=context(req).getModerationController().getEffectiveSignupMode(req); + if(signupMode==Config.SignupMode.CLOSED && StringUtils.isEmpty(invite)) return wrapError(req, resp, "signups_closed"); RenderedTemplateResponse model=new RenderedTemplateResponse("register", req); - model.with("signupMode", Config.signupMode); - if(Config.signupMode==Config.SignupMode.OPEN && Config.signupFormUseCaptcha){ + model.with("signupMode", signupMode); + if(signupMode==Config.SignupMode.OPEN && Config.signupFormUseCaptcha){ model.with("captchaSid", randomAlphanumericString(16)); } if(StringUtils.isNotEmpty(invite)){ @@ -415,9 +442,18 @@ public static Object activateAccount(Request req, Response resp, Account self, A } public static Object requestSignupInvite(Request req, Response resp){ - if(Config.signupMode!=Config.SignupMode.MANUAL_APPROVAL){ + if(context(req).getModerationController().getEffectiveSignupMode(req)!=Config.SignupMode.MANUAL_APPROVAL){ throw new UserActionNotAllowedException(); } + + // Honeypot fields + String password=req.queryParams("password"); + String website=req.queryParams("website"); + if(StringUtils.isNotEmpty(password) || StringUtils.isNotEmpty(website)){ + req.session().attribute("bannedBot", true); + throw new UserActionNotAllowedException(); + } + try{ String email=requireFormField(req, "email", "err_invalid_email"); if(!isValidEmail(email)) @@ -428,11 +464,7 @@ public static Object requestSignupInvite(Request req, Response resp){ lastName=null; String reason=requireFormField(req, "reason", "err_request_invite_reason_empty"); if(Config.signupFormUseCaptcha){ - String captcha=requireFormField(req, "captcha", "err_wrong_captcha"); - String sid=requireFormField(req, "captchaSid", "err_wrong_captcha"); - LruCache captchas=req.session().attribute("captchas"); - if(captchas==null || !captcha.equals(captchas.remove(sid))) - throw new UserErrorException("err_wrong_captcha"); + verifyCaptcha(req); } context(req).getUsersController().requestSignupInvite(req, firstName, lastName, email, reason); return new RenderedTemplateResponse("generic_message", req).with("message", lang(req).get("signup_request_submitted")); @@ -450,4 +482,76 @@ public static Object requestSignupInvite(Request req, Response resp){ return model; } } + + public static Object unfreezeBox(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + if(info.account.user.banStatus!=UserBanStatus.FROZEN || info.account.user.banInfo.expiresAt().isAfter(Instant.now())) + throw new UserActionNotAllowedException(); + return sendEmailConfirmationCode(req, resp, EmailCodeActionType.ACCOUNT_UNFREEZE, "/account/unfreeze"); + } + + public static Object unfreeze(Request req, Response resp, Account self, ApplicationContext ctx){ + if(self.user.banStatus!=UserBanStatus.FROZEN || self.user.banInfo.expiresAt().isAfter(Instant.now())) + throw new UserActionNotAllowedException(); + checkEmailConfirmationCode(req, EmailCodeActionType.ACCOUNT_UNFREEZE); + if(self.user.banInfo.requirePasswordChange()){ + req.session().attribute("emailConfirmationForUnfreezingDone", true); + Lang l=lang(req); + RenderedTemplateResponse model=new RenderedTemplateResponse("account_unfreeze_change_password_form", req); + return wrapForm(req, resp, "account_unfreeze_change_password_form", "/account/unfreezeChangePassword", l.get("change_password"), "save", model); + }else{ + ctx.getModerationController().clearUserBanStatus(self); + if(isAjax(req)) + return new WebDeltaResponse(resp).replaceLocation("/feed"); + resp.redirect("/feed"); + } + return ""; + } + + public static Object unfreezeChangePassword(Request req, Response resp, Account self, ApplicationContext ctx){ + if(self.user.banStatus!=UserBanStatus.FROZEN || self.user.banInfo.expiresAt().isAfter(Instant.now()) || !self.user.banInfo.requirePasswordChange()) + throw new UserActionNotAllowedException(); + if(req.session().attribute("emailConfirmationForUnfreezingDone")==null) + throw new UserActionNotAllowedException(); + requireQueryParams(req, "current", "new", "new2"); + String current=req.queryParams("current"); + String new1=req.queryParams("new"); + String new2=req.queryParams("new2"); + String message; + if(!new1.equals(new2)){ + message=Utils.lang(req).get("err_passwords_dont_match"); + }else{ + try{ + ctx.getUsersController().changePassword(self, current, new1); + ctx.getModerationController().clearUserBanStatus(self); + ctx.getUsersController().terminateSessionsExcept(self, req.cookie("psid")); + if(isAjax(req)) + return new WebDeltaResponse(resp).replaceLocation("/feed"); + resp.redirect("/feed"); + return ""; + }catch(UserErrorException x){ + message=lang(req).get(x.getMessage()); + } + } + if(isAjax(req)){ + return new WebDeltaResponse(resp).keepBox().show("formMessage_changePassword").setContent("formMessage_changePassword", message); + } + Lang l=lang(req); + RenderedTemplateResponse model=new RenderedTemplateResponse("account_unfreeze_change_password_form", req); + model.with("passwordMessage", message); + return wrapForm(req, resp, "account_unfreeze_change_password_form", "/account/unfreezeChangePassword", l.get("change_password"), "save", model); + } + + public static Object reactivateBox(Request req, Response resp, Account self, ApplicationContext ctx){ + return wrapForm(req, resp, "reactivate_account_form", "/account/reactivate", lang(req).get("settings_reactivate_title"), "restore", "reactivateAccount", List.of(), null, null); + } + + public static Object reactivate(Request req, Response resp, Account self, ApplicationContext ctx){ + requireQueryParams(req, "password"); + String password=req.queryParams("password"); + if(!ctx.getUsersController().checkPassword(self, password)){ + return wrapForm(req, resp, "reactivate_account_form", "/account/reactivate", lang(req).get("settings_reactivate_title"), "restore", "reactivateAccount", List.of(), null, lang(req).get("err_old_password_incorrect")); + } + ctx.getUsersController().selfReinstateAccount(self); + return ajaxAwareRedirect(req, resp, "/feed"); + } } diff --git a/src/main/java/smithereen/routes/SettingsAdminRoutes.java b/src/main/java/smithereen/routes/SettingsAdminRoutes.java index cb7f64bd..09dcf40b 100644 --- a/src/main/java/smithereen/routes/SettingsAdminRoutes.java +++ b/src/main/java/smithereen/routes/SettingsAdminRoutes.java @@ -3,34 +3,70 @@ import java.net.URLEncoder; import java.sql.SQLException; import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.IntStream; import smithereen.ApplicationContext; import smithereen.Config; import smithereen.Mailer; -import smithereen.Utils; +import smithereen.SmithereenApplication; +import smithereen.activitypub.objects.Actor; +import smithereen.exceptions.UserErrorException; import smithereen.model.Account; +import smithereen.model.ActorStaffNote; +import smithereen.model.AuditLogEntry; +import smithereen.model.EmailDomainBlockRule; +import smithereen.model.EmailDomainBlockRuleFull; import smithereen.model.FederationRestriction; +import smithereen.model.ForeignUser; +import smithereen.model.Group; +import smithereen.model.IPBlockRule; +import smithereen.model.IPBlockRuleFull; import smithereen.model.MailMessage; +import smithereen.model.OtherSession; import smithereen.model.PaginatedList; import smithereen.model.Post; +import smithereen.model.ReportableContentObject; import smithereen.model.Server; +import smithereen.model.SessionInfo; +import smithereen.model.SignupInvitation; import smithereen.model.StatsPoint; import smithereen.model.StatsType; import smithereen.model.User; +import smithereen.model.UserBanInfo; +import smithereen.model.UserBanStatus; +import smithereen.model.UserRole; import smithereen.model.ViolationReport; +import smithereen.model.ViolationReportAction; import smithereen.model.WebDeltaResponse; import smithereen.exceptions.BadRequestException; import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.lang.Lang; +import smithereen.model.viewmodel.AdminUserViewModel; +import smithereen.model.viewmodel.AuditLogEntryViewModel; +import smithereen.model.viewmodel.UserContentMetrics; +import smithereen.model.viewmodel.UserRelationshipMetrics; +import smithereen.model.viewmodel.UserRoleViewModel; +import smithereen.model.viewmodel.ViolationReportActionViewModel; +import smithereen.storage.ModerationStorage; import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; import smithereen.templates.RenderedTemplateResponse; +import smithereen.util.InetAddressRange; import spark.Request; import spark.Response; import spark.utils.StringUtils; @@ -99,91 +135,114 @@ public static Object updateServerInfo(Request req, Response resp, Account self, public static Object users(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ RenderedTemplateResponse model=new RenderedTemplateResponse("admin_users", req); Lang l=lang(req); - int offset=parseIntOrDefault(req.queryParams("offset"), 0); - List accounts=UserStorage.getAllAccounts(offset, 100); - model.paginate(new PaginatedList<>(accounts, UserStorage.getLocalUserCount(), offset, 100)); + String q=req.queryParams("q"); + Boolean localOnly=switch(req.queryParams("location")){ + case "local" -> true; + case "remote" -> false; + case null, default -> null; + }; + String emailDomain=req.queryParams("emailDomain"); + String lastIP=req.queryParams("lastIP"); + int role=safeParseInt(req.queryParams("role")); + PaginatedList items=ctx.getModerationController().getAllUsers(offset(req), 100, q, localOnly, emailDomain, lastIP, role); + model.paginate(items); + model.with("users", ctx.getUsersController().getUsers(items.list.stream().map(AdminUserViewModel::userID).collect(Collectors.toSet()))); + model.with("accounts", ctx.getModerationController().getAccounts(items.list.stream().map(AdminUserViewModel::accountID).filter(i->i>0).collect(Collectors.toSet()))); model.with("title", l.get("admin_users")+" | "+l.get("menu_admin")).with("toolbarTitle", l.get("menu_admin")); - model.with("wideOnDesktop", true); + model.with("allRoles", Config.userRoles.values().stream().sorted(Comparator.comparingInt(UserRole::id)).toList()); + model.with("rolesMap", Config.userRoles); + String baseURL=getRequestPathAndQuery(req); + model.with("urlPath", baseURL) + .with("location", req.queryParams("location")) + .with("emailDomain", emailDomain) + .with("lastIP", lastIP) + .with("roleID", role) + .with("query", q) + .with("hasFilters", StringUtils.isNotEmpty(q) || localOnly!=null || StringUtils.isNotEmpty(emailDomain) || StringUtils.isNotEmpty(lastIP) || role>0); jsLangKey(req, "cancel", "yes", "no"); + String msg=req.session().attribute("adminSettingsUsersMessage"); + if(msg!=null){ + req.session().removeAttribute("adminSettingsUsersMessage"); + model.with("message", msg); + } + if(isAjax(req)){ + return new WebDeltaResponse(resp) + .setContent("ajaxUpdatable", model.renderBlock("ajaxPartialUpdate")) + .setAttribute("userSearch", "data-base-url", baseURL) + .setURL(baseURL); + } return model; } - public static Object accessLevelForm(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ + public static Object roleForm(Request req, Response resp, Account self, ApplicationContext ctx){ Lang l=lang(req); int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); - Account target=UserStorage.getAccount(accountID); - if(target==null || target.id==self.id) - throw new ObjectNotFoundException("err_user_not_found"); - RenderedTemplateResponse model=new RenderedTemplateResponse("admin_users_access_level", req); + Account target=ctx.getUsersController().getAccountOrThrow(accountID); + if(target.id==self.id) + return wrapError(req, resp, "err_user_not_found"); + UserRole myRole=sessionInfo(req).permissions.role; + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_users_role", req); model.with("targetAccount", target); - return wrapForm(req, resp, "admin_users_access_level", "/settings/admin/users/setAccessLevel", l.get("access_level"), "save", model); + model.with("roles", Config.userRoles.values().stream() + .filter(r->myRole.permissions().contains(UserRole.Permission.SUPERUSER) || r.permissions().containsAll(myRole.permissions())) + .sorted(Comparator.comparingInt(UserRole::id)) + .toList()); + return wrapForm(req, resp, "admin_users_role", "/settings/admin/users/setRole", l.get("role"), "save", model); } - public static Object setUserAccessLevel(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ + public static Object setUserRole(Request req, Response resp, Account self, ApplicationContext ctx){ int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); - Account target=UserStorage.getAccount(accountID); - if(target==null || target.id==self.id) + int role=safeParseInt(req.queryParams("role")); + Account target=ctx.getUsersController().getAccountOrThrow(accountID); + if(target.id==self.id) return wrapError(req, resp, "err_user_not_found"); - try{ - UserStorage.setAccountAccessLevel(target.id, Account.AccessLevel.valueOf(req.queryParams("level"))); - }catch(IllegalArgumentException x){} + ctx.getModerationController().setAccountRole(self, target, role); if(isAjax(req)){ resp.type("application/json"); return "[]"; } return ""; } - - public static Object banUserForm(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ - Lang l=lang(req); - int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); - Account target=UserStorage.getAccount(accountID); - if(target==null || target.id==self.id || target.accessLevel.ordinal()>=Account.AccessLevel.MODERATOR.ordinal()) - throw new ObjectNotFoundException("err_user_not_found"); - RenderedTemplateResponse model=new RenderedTemplateResponse("admin_ban_user", req); - model.with("targetAccount", target); - return wrapForm(req, resp, "admin_ban_user", "/settings/admin/users/ban?accountID="+accountID, l.get("admin_ban"), "admin_ban", model); - } - - public static Object banUser(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ - int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); - Account target=UserStorage.getAccount(accountID); - if(target==null || target.id==self.id || target.accessLevel.ordinal()>=Account.AccessLevel.MODERATOR.ordinal()) - throw new ObjectNotFoundException("err_user_not_found"); - Account.BanInfo banInfo=new Account.BanInfo(); - banInfo.reason=req.queryParams("message"); - banInfo.adminUserId=self.user.id; - banInfo.when=Instant.now(); - UserStorage.putAccountBanInfo(accountID, banInfo); - if(isAjax(req)) - return new WebDeltaResponse(resp).refresh(); - resp.redirect(back(req)); - return ""; - } - - public static Object confirmUnbanUser(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ - req.attribute("noHistory", true); - int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); - Account target=UserStorage.getAccount(accountID); - if(target==null) - throw new ObjectNotFoundException("err_user_not_found"); - Lang l=Utils.lang(req); - String back=Utils.back(req); - User user=target.user; - return new RenderedTemplateResponse("generic_confirm", req).with("message", l.get("admin_unban_X_confirm", Map.of("name", user.getFirstLastAndGender()))).with("formAction", "/settings/admin/users/unban?accountID="+accountID+"&_redir="+URLEncoder.encode(back)).with("back", back); - } - - public static Object unbanUser(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ - int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); - Account target=UserStorage.getAccount(accountID); - if(target==null) - throw new ObjectNotFoundException("err_user_not_found"); - UserStorage.putAccountBanInfo(accountID, null); - if(isAjax(req)) - return new WebDeltaResponse(resp).refresh(); - resp.redirect(back(req)); - return ""; - } +// +// public static Object banUser(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ +// int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); +// Account target=UserStorage.getAccount(accountID); +// if(target==null || target.id==self.id || target.roleID!=0) +// throw new ObjectNotFoundException("err_user_not_found"); +// Account.BanInfo banInfo=new Account.BanInfo(); +// banInfo.reason=req.queryParams("message"); +// banInfo.adminUserId=self.user.id; +// banInfo.when=Instant.now(); +// UserStorage.putAccountBanInfo(accountID, banInfo); +// if(isAjax(req)) +// return new WebDeltaResponse(resp).refresh(); +// resp.redirect(back(req)); +// return ""; +// } +// +// public static Object confirmUnbanUser(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ +// req.attribute("noHistory", true); +// int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); +// Account target=UserStorage.getAccount(accountID); +// if(target==null) +// throw new ObjectNotFoundException("err_user_not_found"); +// Lang l=Utils.lang(req); +// String back=Utils.back(req); +// User user=target.user; +// return new RenderedTemplateResponse("generic_confirm", req).with("message", l.get("admin_unban_X_confirm", Map.of("name", user.getFirstLastAndGender()))).with("formAction", "/settings/admin/users/unban?accountID="+accountID+"&_redir="+URLEncoder.encode(back)).with("back", back); +// } +// +// public static Object unbanUser(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ +// int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); +// Account target=UserStorage.getAccount(accountID); +// if(target==null) +// throw new ObjectNotFoundException("err_user_not_found"); +// UserStorage.putAccountBanInfo(accountID, null); +// if(isAjax(req)) +// return new WebDeltaResponse(resp).refresh(); +// resp.redirect(back(req)); +// return ""; +// } public static Object otherSettings(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ Lang l=lang(req); @@ -274,8 +333,8 @@ public static Object confirmActivateAccount(Request req, Response resp, Account Account target=UserStorage.getAccount(accountID); if(target==null) throw new ObjectNotFoundException("err_user_not_found"); - Lang l=Utils.lang(req); - String back=Utils.back(req); + Lang l=lang(req); + String back=back(req); User user=target.user; return new RenderedTemplateResponse("generic_confirm", req).with("message", l.get("admin_activate_X_confirm", Map.of("name", user.getFirstLastAndGender()))).with("formAction", "/settings/admin/users/activate?accountID="+accountID+"&_redir="+URLEncoder.encode(back)).with("back", back); } @@ -286,7 +345,15 @@ public static Object activateAccount(Request req, Response resp, Account self, A Account target=UserStorage.getAccount(accountID); if(target==null) throw new ObjectNotFoundException("err_user_not_found"); + if(target.activationInfo==null || target.activationInfo.emailState!=Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED){ + if(!isAjax(req)) + resp.redirect(back(req)); + return ""; + } + ModerationStorage.createAuditLogEntry(self.user.id, AuditLogEntry.Action.ACTIVATE_ACCOUNT, target.user.id, 0, null, null); SessionStorage.updateActivationInfo(accountID, null); + UserStorage.removeAccountFromCache(accountID); + SmithereenApplication.invalidateAllSessionsForAccount(accountID); if(isAjax(req)) return new WebDeltaResponse(resp).refresh(); resp.redirect(back(req)); @@ -317,7 +384,7 @@ public static Object respondToSignupRequest(Request req, Response resp, Account return new WebDeltaResponse(resp).setContent("signupReqBtns"+id, "
"+lang(req).get(accept ? "email_invite_sent" : "signup_request_deleted")+"
"); } - resp.redirect(Utils.back(req)); + resp.redirect(back(req)); return ""; } @@ -339,7 +406,10 @@ public static Object federationServerList(Request req, Response resp, Account se .with("onlyRestricted", onlyRestricted) .with("query", q); if(isAjax(req)){ - return new WebDeltaResponse(resp).setContent("ajaxUpdatable", model.renderBlock("ajaxPartialUpdate")).setAttribute("domainSearch", "data-base-url", baseURL); + return new WebDeltaResponse(resp) + .setContent("ajaxUpdatable", model.renderBlock("ajaxPartialUpdate")) + .setAttribute("domainSearch", "data-base-url", baseURL) + .setURL(baseURL); } return model; } @@ -372,7 +442,6 @@ public static Object federationServerDetails(Request req, Response resp, Account List.of(sentActivities, recvdActivities, failedActivities), timeZoneForRequest(req) ).toString()); - System.out.println(gd); } return model; } @@ -463,91 +532,1004 @@ public static Object reportsList(Request req, Response resp, Account self, Appli PaginatedList reports=ctx.getModerationController().getViolationReports(!resolved, offset(req), 50); model.paginate(reports); - Set userIDs=reports.list.stream().filter(r->r.targetType==ViolationReport.TargetType.USER).map(r->r.targetID).collect(Collectors.toSet()); + Set userIDs=reports.list.stream().filter(r->r.targetID>0).map(r->r.targetID).collect(Collectors.toSet()); userIDs.addAll(reports.list.stream().filter(r->r.reporterID!=0).map(r->r.reporterID).collect(Collectors.toSet())); - Set groupIDs=reports.list.stream().filter(r->r.targetType==ViolationReport.TargetType.GROUP).map(r->r.targetID).collect(Collectors.toSet()); - Set postIDs=reports.list.stream().filter(r->r.contentType==ViolationReport.ContentType.POST).map(r->(int)r.contentID).collect(Collectors.toSet()); - Set messageIDs=reports.list.stream().filter(r->r.contentType==ViolationReport.ContentType.MESSAGE).map(r->r.contentID).collect(Collectors.toSet()); - - Map posts=ctx.getWallController().getPosts(postIDs); - Map messages=ctx.getMailController().getMessagesAsModerator(messageIDs); - for(Post post:posts.values()){ - userIDs.add(post.authorID); - if(post.ownerID!=post.authorID){ + Set groupIDs=reports.list.stream().filter(r->r.targetID<0).map(r->-r.targetID).collect(Collectors.toSet()); + + model.with("users", ctx.getUsersController().getUsers(userIDs)) + .with("groups", ctx.getGroupsController().getGroupsByIdAsMap(groupIDs)); + + return model; + } + + public static Object viewReport(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ViolationReport report=ctx.getModerationController().getViolationReportByID(id, false); + HashSet needUsers=new HashSet<>(), needGroups=new HashSet<>(); + HashSet needPosts=new HashSet<>(); + HashSet needMessages=new HashSet<>(); + + if(report.targetID>0) + needUsers.add(report.targetID); + if(report.targetID<0) + needGroups.add(-report.targetID); + needUsers.add(report.reporterID); + + ArrayList> contentForTemplate=new ArrayList<>(); + int i=0; + for(ReportableContentObject co:report.content){ + switch(co){ + case Post p -> { + needPosts.add(p.id); + contentForTemplate.add(Map.of("type", p.getReplyLevel()>0 ? "comment" : "post", "id", p.id, "url", "/settings/admin/reports/"+id+"/content/"+i)); + } + case MailMessage msg -> { + needMessages.add(msg.id); + contentForTemplate.add(Map.of("type", "message", "id", msg.id, "url", "/settings/admin/reports/"+id+"/content/"+i)); + } + } + i++; + } + List actions=ctx.getModerationController().getViolationReportActions(report); + needUsers.addAll(actions.stream().map(ViolationReportAction::userID).collect(Collectors.toSet())); + + Map posts=ctx.getWallController().getPosts(needPosts); + Map messages=ctx.getMailController().getMessagesAsModerator(needMessages); + Map users=ctx.getUsersController().getUsers(needUsers); + Map groups=ctx.getGroupsController().getGroupsByIdAsMap(needGroups); + + Actor target=report.targetID>0 ? users.get(report.targetID) : groups.get(-report.targetID); + + Lang l=lang(req); + List actionViewModels=actions.stream().map(a->{ + User adminUser=users.get(a.userID()); + HashMap links=new HashMap<>(); + links.put("adminUser", Map.of("href", adminUser!=null ? adminUser.getProfileURL() : "/id"+a.userID())); + HashMap langArgs=new HashMap<>(); + langArgs.put("name", adminUser!=null ? adminUser.getFullName() : "DELETED"); + langArgs.put("gender", adminUser!=null ? adminUser.gender : User.Gender.UNKNOWN); + String mainText=switch(a.actionType()){ + default -> null; + case REOPEN -> l.get("report_log_reopened", langArgs); + case RESOLVE_REJECT -> l.get("report_log_rejected", langArgs); + case COMMENT -> l.get("report_log_commented", langArgs); + case RESOLVE_WITH_ACTION -> { + if(report.targetID>0){ + User targetUser=(User)target; + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+report.targetID)); + } + yield l.get("admin_audit_log_changed_user_restrictions", langArgs); + } + case DELETE_CONTENT -> l.get("report_log_deleted_content", langArgs); + }; + return new ViolationReportActionViewModel(a, substituteLinks(mainText, links), switch(a.actionType()){ + default -> null; + case COMMENT -> postprocessPostHTMLForDisplay(a.text()); + case RESOLVE_WITH_ACTION -> { + User targetUser=users.get(report.targetID); + String statusStr=switch(UserBanStatus.valueOf(a.extra().get("status").getAsString())){ + case NONE -> l.get("admin_user_state_no_restrictions"); + case FROZEN -> l.get("admin_user_state_frozen", Map.of("expirationTime", l.formatDate(Instant.ofEpochMilli(a.extra().get("expiresAt").getAsLong()), timeZoneForRequest(req), false))); + case SUSPENDED -> { + if(targetUser instanceof ForeignUser) + yield l.get("admin_user_state_suspended_foreign"); + else + yield l.get("admin_user_state_suspended", Map.of("deletionTime", l.formatDate(a.time().plus(30, ChronoUnit.DAYS), timeZoneForRequest(req), false))); + } + case HIDDEN -> l.get("admin_user_state_hidden"); + case SELF_DEACTIVATED -> null; + }; + if(a.extra().has("message")){ + statusStr+="
"+l.get("admin_user_ban_message")+": "+escapeHTML(a.extra().get("message").getAsString()); + } + yield statusStr; + } + }); + }).toList(); + + RenderedTemplateResponse model=new RenderedTemplateResponse("report", req); + model.pageTitle(lang(req).get("admin_report_title_X", Map.of("id", id))); + model.with("report", report); + model.with("users", users).with("groups", groups); + model.with("posts", posts).with("messages", messages); + model.with("canDeleteContent", (!posts.isEmpty() || !messages.isEmpty()) && target!=null); + model.with("actions", actionViewModels); + model.with("content", contentForTemplate); + model.with("isLocalTarget", target!=null && StringUtils.isEmpty(target.domain)); + model.with("toolbarTitle", l.get("menu_reports")); + return model; + } + + public static Object reportMarkResolved(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ViolationReport report=ctx.getModerationController().getViolationReportByID(id, false); + if(report.state!=ViolationReport.State.OPEN) + throw new BadRequestException(); + ctx.getModerationController().rejectViolationReport(report, self.user); + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object reportMarkUnresolved(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ViolationReport report=ctx.getModerationController().getViolationReportByID(id, false); + if(report.state==ViolationReport.State.OPEN) + throw new BadRequestException(); + ctx.getModerationController().markViolationReportUnresolved(report, self.user); + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object reportAddComment(Request req, Response resp, Account self, ApplicationContext ctx){ + requireQueryParams(req, "text"); + int id=safeParseInt(req.params(":id")); + ViolationReport report=ctx.getModerationController().getViolationReportByID(id, false); + String text=req.queryParams("text"); + ctx.getModerationController().addViolationReportComment(report, self.user, text); + if(isAjax(req)) + return new WebDeltaResponse(resp).setContent("commentText", "").refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object reportShowContent(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ViolationReport report=ctx.getModerationController().getViolationReportByID(id, true); + int index=safeParseInt(req.params(":index")); + if(index<0 || index>=report.content.size()) + throw new BadRequestException(); + ReportableContentObject cobj=report.content.get(index); + RenderedTemplateResponse model=new RenderedTemplateResponse("report_content", req); + Lang l=lang(req); + String title; + model.with("content", cobj); + model.with("reportID", id); + HashSet needUsers=new HashSet<>(), needGroups=new HashSet<>(); + switch(cobj){ + case Post post -> { + title=l.get(post.getReplyLevel()>0 ? "admin_report_content_comment" : "admin_report_content_post", Map.of("id", post.id)); + model.with("contentType", "post"); + needUsers.add(post.authorID); if(post.ownerID>0) - userIDs.add(post.ownerID); + needUsers.add(post.ownerID); else - groupIDs.add(-post.ownerID); + needGroups.add(-post.ownerID); + } + case MailMessage msg -> { + title=l.get("admin_report_content_message", Map.of("id", msg.id)); + model.with("contentType", "message"); + needUsers.addAll(msg.to); + if(msg.cc!=null) + needUsers.addAll(msg.cc); + needUsers.add(msg.senderID); } } - for(MailMessage msg:messages.values()){ - userIDs.add(msg.senderID); + model.with("users", ctx.getUsersController().getUsers(needUsers)); + model.with("groups", ctx.getGroupsController().getGroupsByIdAsMap(needGroups)); + if(isAjax(req)){ + return new WebDeltaResponse(resp).box(title, model.renderBlock("content"), null, !isMobile(req)); } + model.pageTitle(title); + return model; + } - model.with("users", ctx.getUsersController().getUsers(userIDs)) - .with("groups", ctx.getGroupsController().getGroupsByIdAsMap(groupIDs)) - .with("posts", posts) - .with("messages", messages); + public static Object reportConfirmDeleteContent(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ctx.getModerationController().getViolationReportByID(id, false); + Lang l=lang(req); + return wrapConfirmation(req, resp, l.get("report_delete_content_title"), l.get("report_confirm_delete_content"), "/settings/admin/reports/"+id+"/deleteContent"); + } + public static Object reportDeleteContent(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ViolationReport report=ctx.getModerationController().getViolationReportByID(id, true); + ctx.getModerationController().deleteViolationReportContent(report, info, true); + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object roles(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + jsLangKey(req, "admin_delete_role", "admin_delete_role_confirm", "yes", "no"); + List roles=ctx.getModerationController().getRoles(info.permissions); + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_roles", req) + .pageTitle(lang(req).get("admin_roles")) + .with("toolbarTitle", lang(req).get("menu_admin")) + .with("roles", roles); + String msg=req.session().attribute("adminRolesMessage"); + if(StringUtils.isNotEmpty(msg)){ + req.session().removeAttribute("adminRolesMessage"); + model.with("message", msg); + } return model; } - public static Object reportAction(Request req, Response resp, Account self, ApplicationContext ctx){ - ViolationReport report=ctx.getModerationController().getViolationReportByID(safeParseInt(req.params(":id"))); - if(report.actionTime!=null) - throw new BadRequestException("already resolved"); - if(req.queryParams("resolve")!=null){ - ctx.getModerationController().setViolationReportResolved(report, self.user); - if(isAjax(req)) - return new WebDeltaResponse(resp).refresh(); + public static Object editRole(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + UserRole role=Config.userRoles.get(safeParseInt(req.params(":id"))); + if(role==null) + throw new ObjectNotFoundException(); + UserRole myRole=info.permissions.role; + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_edit_role", req); + model.pageTitle(lang(req).get("admin_edit_role_title")); + model.with("role", role); + if(role.id()==1){ + model.with("permissions", List.of(UserRole.Permission.SUPERUSER)); + model.with("disabledPermissions", EnumSet.of(UserRole.Permission.SUPERUSER)); + }else{ + model.with("permissions", Arrays.stream(UserRole.Permission.values()).filter(p->p!=UserRole.Permission.VISIBLE_IN_STAFF && (myRole.permissions().contains(UserRole.Permission.SUPERUSER) || myRole.permissions().contains(p))).toList()); + if(role.id()==myRole.id()) + model.with("disabledPermissions", EnumSet.allOf(UserRole.Permission.class)); + else + model.with("disabledPermissions", EnumSet.noneOf(UserRole.Permission.class)); + } + if(info.permissions.hasPermission(UserRole.Permission.VISIBLE_IN_STAFF)) + model.with("settings", List.of(UserRole.Permission.VISIBLE_IN_STAFF)); + return model; + } + + public static Object saveRole(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + String _id=req.params(":id"); + UserRole role; + if(StringUtils.isNotEmpty(_id)){ + role=Config.userRoles.get(safeParseInt(_id)); + if(role==null) + throw new ObjectNotFoundException(); + }else{ + role=null; + } + requireQueryParams(req, "name"); + String name=req.queryParams("name"); + EnumSet permissions=EnumSet.noneOf(UserRole.Permission.class); + for(UserRole.Permission permission:UserRole.Permission.values()){ + if("on".equals(req.queryParams(permission.toString()))) + permissions.add(permission); + } + if(permissions.isEmpty()){ + if(isAjax(req)){ + return new WebDeltaResponse(resp).show("formMessage_editRole").setContent("formMessage_editRole", lang(req).get("admin_no_permissions_selected")); + } resp.redirect(back(req)); - }else if(req.queryParams("deleteContent")!=null){ - // TODO notify user - if(report.contentType==ViolationReport.ContentType.POST){ - Post post=ctx.getWallController().getPostOrThrow((int)report.contentID); - ctx.getWallController().deletePostAsServerModerator(sessionInfo(req), post); - }else{ - throw new BadRequestException(); + return ""; + } + if(role!=null){ + ctx.getModerationController().updateRole(info.account.user, info.permissions, role, name, permissions); + req.session().attribute("adminRolesMessage", lang(req).get("admin_role_X_saved", Map.of("name", name))); + }else{ + ctx.getModerationController().createRole(info.account.user, info.permissions, name, permissions); + req.session().attribute("adminRolesMessage", lang(req).get("admin_role_X_created", Map.of("name", name))); + } + if(isAjax(req)){ + return new WebDeltaResponse(resp).replaceLocation("/settings/admin/roles"); + } + resp.redirect("/settings/admin/roles"); + return ""; + } + + public static Object createRoleForm(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + UserRole myRole=info.permissions.role; + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_edit_role", req); + model.pageTitle(lang(req).get("admin_create_role_title")); + model.with("permissions", Arrays.stream(UserRole.Permission.values()).filter(p->p!=UserRole.Permission.VISIBLE_IN_STAFF && (myRole.permissions().contains(UserRole.Permission.SUPERUSER) || myRole.permissions().contains(p))).toList()); + model.with("disabledPermissions", EnumSet.noneOf(UserRole.Permission.class)); + if(info.permissions.hasPermission(UserRole.Permission.VISIBLE_IN_STAFF)) + model.with("settings", List.of(UserRole.Permission.VISIBLE_IN_STAFF)); + return model; + } + + public static Object deleteRole(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + UserRole role=Config.userRoles.get(safeParseInt(req.params(":id"))); + if(role==null) + throw new ObjectNotFoundException(); + ctx.getModerationController().deleteRole(info.account.user, info.permissions, role); + if(isAjax(req)){ + return new WebDeltaResponse(resp).remove("roleRow"+role.id()); + } + resp.redirect("/settings/admin/roles"); + return ""; + } + + public static Object auditLog(Request req, Response resp, Account self, ApplicationContext ctx){ + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_audit_log", req); + PaginatedList log; + if(req.queryParams("uid")!=null){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.queryParams("uid"))); + model.with("user", user); + model.with("staffNoteCount", ctx.getModerationController().getUserStaffNoteCount(user)); + log=ctx.getModerationController().getUserAuditLog(user, offset(req), 100); + }else{ + log=ctx.getModerationController().getGlobalAuditLog(offset(req), 100); + } + Map users=ctx.getUsersController().getUsers( + IntStream.concat(log.list.stream().mapToInt(AuditLogEntry::ownerID), log.list.stream().mapToInt(AuditLogEntry::adminID)) + .filter(id->id>0) + .boxed() + .collect(Collectors.toSet()) + ); + Map groups=ctx.getGroupsController().getGroupsByIdAsMap(log.list.stream().map(AuditLogEntry::ownerID).filter(id->id<0).map(id->-id).collect(Collectors.toSet())); + final Lang l=lang(req); + List viewModels=log.list.stream().map(le->{ + User adminUser=users.get(le.adminID()); + HashMap links=new HashMap<>(); + links.put("adminUser", Map.of("href", adminUser!=null ? adminUser.getProfileURL() : "/id"+le.adminID())); + HashMap langArgs=new HashMap<>(); + langArgs.put("name", adminUser!=null ? adminUser.getFullName() : "DELETED"); + langArgs.put("gender", adminUser!=null ? adminUser.gender : User.Gender.UNKNOWN); + String mainText=switch(le.action()){ + case CREATE_ROLE -> { + langArgs.put("roleName", le.extra().get("name")); + yield l.get("admin_audit_log_created_role", langArgs); + } + case EDIT_ROLE -> { + UserRole role=Config.userRoles.get((int)le.objectID()); + langArgs.put("roleName", role!=null ? role.name() : "#"+le.objectID()); + yield l.get("admin_audit_log_edited_role", langArgs); + } + case DELETE_ROLE -> { + langArgs.put("roleName", le.extra().get("name")); + yield l.get("admin_audit_log_deleted_role", langArgs); + } + case ASSIGN_ROLE -> { + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + if(le.objectID()==0){ + yield l.get("admin_audit_log_unassigned_role", langArgs); + }else{ + UserRole role=Config.userRoles.get((int)le.objectID()); + langArgs.put("roleName", role!=null ? role.name() : "#"+le.objectID()); + yield l.get("admin_audit_log_assigned_role", langArgs); + } + } + + case SET_USER_EMAIL ->{ + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + yield l.get("admin_audit_log_changed_email", langArgs); + } + case ACTIVATE_ACCOUNT -> { + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + yield l.get("admin_audit_log_activated_account", langArgs); + } + case RESET_USER_PASSWORD -> { + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + yield l.get("admin_audit_log_reset_password", langArgs); + } + case END_USER_SESSION -> { + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + yield l.get("admin_audit_log_ended_session", langArgs); + } + case BAN_USER -> { + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + yield l.get("admin_audit_log_changed_user_restrictions", langArgs); + } + case DELETE_USER -> { + langArgs.put("targetName", le.extra().get("name")); + links.put("targetUser", Map.of("href", "/id"+le.ownerID())); + yield l.get("admin_audit_log_deleted_user_account", langArgs); + } + + case CREATE_EMAIL_DOMAIN_RULE -> { + langArgs.put("domain", le.extra().get("domain")); + yield l.get("admin_audit_log_created_email_rule", langArgs); + } + case UPDATE_EMAIL_DOMAIN_RULE -> { + langArgs.put("domain", le.extra().get("domain")); + yield l.get("admin_audit_log_updated_email_rule", langArgs); + } + case DELETE_EMAIL_DOMAIN_RULE -> { + langArgs.put("domain", le.extra().get("domain")); + yield l.get("admin_audit_log_deleted_email_rule", langArgs); + } + case CREATE_IP_RULE -> { + langArgs.put("ipOrSubnet", le.extra().get("addr")); + yield l.get("admin_audit_log_created_ip_rule", langArgs); + } + case UPDATE_IP_RULE -> { + langArgs.put("ipOrSubnet", le.extra().get("addr")); + yield l.get("admin_audit_log_updated_ip_rule", langArgs); + } + case DELETE_IP_RULE -> { + langArgs.put("ipOrSubnet", le.extra().get("addr")); + yield l.get("admin_audit_log_deleted_ip_rule", langArgs); + } + + case DELETE_SIGNUP_INVITE -> { + User targetUser=users.get(le.ownerID()); + langArgs.put("targetName", targetUser!=null ? targetUser.getFirstLastAndGender() : "DELETED"); + links.put("targetUser", Map.of("href", targetUser!=null ? targetUser.getProfileURL() : "/id"+le.ownerID())); + yield l.get("admin_audit_log_deleted_invite", langArgs); + } + }; + String extraText=switch(le.action()){ + case ASSIGN_ROLE, DELETE_ROLE, ACTIVATE_ACCOUNT, RESET_USER_PASSWORD, DELETE_USER -> null; + + case CREATE_ROLE -> { + StringBuilder sb=new StringBuilder(""); + EnumSet permissions=EnumSet.noneOf(UserRole.Permission.class); + deserializeEnumSet(permissions, UserRole.Permission.class, Base64.getDecoder().decode((String) le.extra().get("permissions"))); + for(UserRole.Permission permission:permissions){ + sb.append("
+ "); + sb.append(l.get(permission.getLangKey())); + sb.append("
"); + } + sb.append("
"); + yield sb.toString(); + } + case EDIT_ROLE -> { + StringBuilder sb=new StringBuilder(""); + if(le.extra().containsKey("oldName")){ + sb.append("
"); + sb.append(l.get("admin_role_name")); + sb.append(": \""); + sb.append(le.extra().get("oldName")); + sb.append("\" → \""); + sb.append(le.extra().get("newName")); + sb.append("\"
"); + } + if(le.extra().containsKey("oldPermissions")){ + EnumSet oldPermissions=EnumSet.noneOf(UserRole.Permission.class); + deserializeEnumSet(oldPermissions, UserRole.Permission.class, Base64.getDecoder().decode((String) le.extra().get("oldPermissions"))); + EnumSet newPermissions=EnumSet.noneOf(UserRole.Permission.class); + deserializeEnumSet(newPermissions, UserRole.Permission.class, Base64.getDecoder().decode((String) le.extra().get("newPermissions"))); + for(UserRole.Permission permission:oldPermissions){ + if(!newPermissions.contains(permission)){ + sb.append("
- "); + sb.append(l.get(permission.getLangKey())); + sb.append("
"); + } + } + for(UserRole.Permission permission:newPermissions){ + if(!oldPermissions.contains(permission)){ + sb.append("
+ "); + sb.append(l.get(permission.getLangKey())); + sb.append("
"); + } + } + } + sb.append("
"); + yield sb.toString(); + } + + case SET_USER_EMAIL -> escapeHTML(le.extra().get("oldEmail").toString())+" → "+escapeHTML(le.extra().get("newEmail").toString()); + case END_USER_SESSION -> l.get("ip_address")+": "+deserializeInetAddress(Base64.getDecoder().decode((String)le.extra().get("ip"))).getHostAddress(); + case BAN_USER -> { + User targetUser=users.get(le.ownerID()); + String statusStr=switch(UserBanStatus.valueOf((String)le.extra().get("status"))){ + case NONE -> l.get("admin_user_state_no_restrictions"); + case FROZEN -> l.get("admin_user_state_frozen", Map.of("expirationTime", l.formatDate(Instant.ofEpochMilli(((Number)le.extra().get("expiresAt")).longValue()), timeZoneForRequest(req), false))); + case SUSPENDED -> { + if(targetUser instanceof ForeignUser) + yield l.get("admin_user_state_suspended_foreign"); + else + yield l.get("admin_user_state_suspended", Map.of("deletionTime", l.formatDate(le.time().plus(30, ChronoUnit.DAYS), timeZoneForRequest(req), false))); + } + case HIDDEN -> l.get("admin_user_state_hidden"); + case SELF_DEACTIVATED -> null; + }; + if(le.extra().get("message")!=null){ + statusStr+="
"+l.get("admin_user_ban_message")+": "+escapeHTML((String)le.extra().get("message")); + } + yield statusStr; + } + + case CREATE_EMAIL_DOMAIN_RULE, DELETE_EMAIL_DOMAIN_RULE -> ""+l.get("admin_rule_action")+": "+l.get(EmailDomainBlockRule.Action.valueOf((String)le.extra().get("action")).getLangKey())+""; + case UPDATE_EMAIL_DOMAIN_RULE -> ""+l.get("admin_rule_action")+": " + +l.get(EmailDomainBlockRule.Action.valueOf((String)le.extra().get("oldAction")).getLangKey()) + +" → " + +l.get(EmailDomainBlockRule.Action.valueOf((String)le.extra().get("newAction")).getLangKey()) + +""; + case CREATE_IP_RULE, DELETE_IP_RULE -> ""+l.get("admin_rule_action")+": "+l.get(IPBlockRule.Action.valueOf((String)le.extra().get("action")).getLangKey())+"
" + +l.get("admin_ip_rule_expiry")+": "+l.formatDate(Instant.ofEpochSecond(((Number)le.extra().get("expiry")).longValue()), timeZoneForRequest(req), true)+"
"; + case UPDATE_IP_RULE -> { + ArrayList lines=new ArrayList<>(); + if(le.extra().containsKey("oldAction")){ + lines.add(l.get("admin_rule_action")+": "+l.get(IPBlockRule.Action.valueOf((String)le.extra().get("oldAction")).getLangKey()) + +" → "+l.get(IPBlockRule.Action.valueOf((String)le.extra().get("newAction")).getLangKey())); + } + if(le.extra().containsKey("oldExpiry")){ + lines.add(l.get("admin_ip_rule_expiry")+": "+l.formatDate(Instant.ofEpochSecond(((Number)le.extra().get("oldExpiry")).longValue()), timeZoneForRequest(req), true) + +" → "+l.formatDate(Instant.ofEpochSecond(((Number)le.extra().get("newExpiry")).longValue()), timeZoneForRequest(req), true)); + } + yield ""+String.join("
", lines)+"
"; + } + + case DELETE_SIGNUP_INVITE -> { + String r=l.get("invite_signup_count")+": "+((Number)le.extra().get("signups")).intValue(); + if(le.extra().containsKey("email")) + r+="
"+l.get("email")+": "+escapeHTML(le.extra().get("email").toString()); + if(le.extra().containsKey("name")) + r+="
"+l.get("name")+": "+escapeHTML(le.extra().get("name").toString()); + yield r; + } + }; + return new AuditLogEntryViewModel(le, substituteLinks(mainText, links), extraText); + }).toList(); + model.pageTitle(lang(req).get("admin_audit_log")).with("toolbarTitle", lang(req).get("menu_admin")).with("users", users).with("groups", groups); + model.paginate(new PaginatedList<>(log, viewModels)); + return model; + } + + public static Object userInfo(Request req, Response resp, SessionInfo info, ApplicationContext ctx){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_users_info", req); + model.with("user", user); + Account account; + if(!(user instanceof ForeignUser)){ + account=ctx.getUsersController().getAccountForUser(user); + model.with("account", account); + if(account.roleID>0){ + UserRole role=Config.userRoles.get(account.roleID); + String roleKey=role.getLangKey(); + model.with("roleTitle", StringUtils.isNotEmpty(roleKey) ? lang(req).get(roleKey) : role.name()); + } + if(account.inviterAccountID>0){ + try{ + model.with("inviter", ctx.getUsersController().getAccountOrThrow(account.inviterAccountID).user); + }catch(ObjectNotFoundException ignore){} + } + model.with("sessions", ctx.getUsersController().getAccountSessions(account)); + if(user.banInfo!=null){ + if(user.domain==null && (user.banStatus==UserBanStatus.SUSPENDED || user.banStatus==UserBanStatus.SELF_DEACTIVATED)){ + model.with("accountDeletionTime", user.banInfo.bannedAt().plus(30, ChronoUnit.DAYS)); + } + try{ + model.with("banModerator", ctx.getUsersController().getUserOrThrow(user.banInfo.moderatorID())); + }catch(ObjectNotFoundException ignore){} } + }else{ + account=null; + } + UserRelationshipMetrics relMetrics=ctx.getUsersController().getRelationshipMetrics(user); + UserContentMetrics contentMetrics=ctx.getUsersController().getContentMetrics(user); + model.with("relationshipMetrics", relMetrics).with("contentMetrics", contentMetrics); + model.pageTitle(lang(req).get("admin_manage_user")+" | "+user.getFullName()); + model.with("staffNoteCount", ctx.getModerationController().getUserStaffNoteCount(user)); + return model; + } - ctx.getModerationController().setViolationReportResolved(report, self.user); - if(isAjax(req)) - return new WebDeltaResponse(resp).refresh(); - resp.redirect(back(req)); - }else if(req.queryParams("addCW")!=null){ - if(report.contentType==ViolationReport.ContentType.POST){ - Post post=ctx.getWallController().getPostOrThrow((int)report.contentID); - if(post.hasContentWarning()) - throw new BadRequestException(); - - Lang l=lang(req); - return wrapForm(req, resp, "admin_add_cw", "/settings/admin/reports/"+report.id+"/doAddCW", l.get("post_form_cw"), "save", "addCW", List.of(), Function.identity(), null); - }else{ - throw new BadRequestException(); + public static Object changeUserEmailForm(Request req, Response resp, Account self, ApplicationContext ctx){ + Account target=ctx.getUsersController().getAccountOrThrow(safeParseInt(req.queryParams("accountID"))); + RenderedTemplateResponse model=new RenderedTemplateResponse("change_email_form", req); + model.with("email", target.email); + return wrapForm(req, resp, "change_email_form", "/settings/admin/users/changeEmail?accountID="+target.id, lang(req).get("change_email_title"), "save", model); + } + + public static Object changeUserEmail(Request req, Response resp, Account self, ApplicationContext ctx){ + Account target=ctx.getUsersController().getAccountOrThrow(safeParseInt(req.queryParams("accountID"))); + String email=req.queryParams("email"); + if(!isValidEmail(email)) + throw new BadRequestException(); + ctx.getModerationController().setUserEmail(self.user, target, email); + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object endUserSession(Request req, Response resp, Account self, ApplicationContext ctx){ + Account target=ctx.getUsersController().getAccountOrThrow(safeParseInt(req.queryParams("accountID"))); + int sessionID=safeParseInt(req.queryParams("sessionID")); + List sessions=ctx.getUsersController().getAccountSessions(target); + OtherSession sessionToRevoke=null; + for(OtherSession session:sessions){ + if(session.id()==sessionID){ + sessionToRevoke=session; + break; } } + if(sessionToRevoke==null) + throw new ObjectNotFoundException(); + + ctx.getModerationController().terminateUserSession(self.user, target, sessionToRevoke); + + if(isAjax(req)){ + return new WebDeltaResponse(resp) + .addClass("adminSessionRow"+sessionID, "transparent") + .addClass("adminSessionRow"+sessionID, "disabled") + .showSnackbar(lang(req).get("admin_session_terminated")) + .hide("boxLoader"); + } + resp.redirect(back(req)); return ""; } - public static Object reportAddCW(Request req, Response resp, Account self, ApplicationContext ctx){ - ViolationReport report=ctx.getModerationController().getViolationReportByID(safeParseInt(req.params(":id"))); - if(report.actionTime!=null) - throw new BadRequestException("already resolved"); - requireQueryParams(req, "cw"); + public static Object banUserForm(Request req, Response resp, Account self, ApplicationContext ctx){ + ViolationReport report; + boolean deleteReportContent; + if(req.queryParams("report")!=null){ + report=ctx.getModerationController().getViolationReportByID(safeParseInt(req.queryParams("report")), false); + deleteReportContent=req.queryParams("deleteContent")!=null; + }else{ + report=null; + deleteReportContent=false; + } + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + Lang l=lang(req); + String formAction="/users/"+user.id+"/ban"; + if(report!=null){ + formAction+="?report="+report.id; + if(deleteReportContent) + formAction+="&deleteContent"; + } + Object form=wrapForm(req, resp, "admin_users_ban_form", formAction, l.get("admin_ban_user_title"), + "save", "banUser", List.of("status", "message", /*"duration",*/ "forcePasswordChange"), s->switch(s){ + case "status" -> user.banStatus; + case "message" -> user.banInfo!=null ? user.banInfo.message() : null; + case "forcePasswordChange" -> user.banInfo!=null && user.banInfo.requirePasswordChange(); + default -> throw new IllegalStateException("Unexpected value: " + s); + }, null, Map.of("user", user, "hideNone", report!=null, "deleteReportContent", deleteReportContent)); + if(user.domain==null && form instanceof WebDeltaResponse wdr){ + wdr.runScript(""" + function userBanForm_updateFieldVisibility(){ + var message=ge("formRow_message"); + var duration=ge("formRow_duration"); + var forcePasswordChange=ge("formRow_forcePasswordChange"); + var freezeChecked=ge("status1").checked; + var suspendChecked=ge("status2").checked; + if(freezeChecked || suspendChecked){ + message.show(); + }else{ + message.hide(); + } + if(freezeChecked){ + duration.show(); + forcePasswordChange.show(); + }else{ + duration.hide(); + forcePasswordChange.hide(); + } + } + for(var i=0;i<4;i++){ + var el=ge("status"+i); + if(!el) continue; + el.addEventListener("change", function(){userBanForm_updateFieldVisibility();}, false); + } + userBanForm_updateFieldVisibility();"""); + } + return form; + } - // TODO notify user - if(report.contentType==ViolationReport.ContentType.POST){ - Post post=ctx.getWallController().getPostOrThrow((int)report.contentID); - ctx.getWallController().setPostCWAsModerator(sessionInfo(req).permissions, post, req.queryParams("cw")); + public static Object banUser(Request req, Response resp, Account self, ApplicationContext ctx){ + ViolationReport report; + boolean deleteReportContent; + if(req.queryParams("report")!=null){ + report=ctx.getModerationController().getViolationReportByID(safeParseInt(req.queryParams("report")), false); + deleteReportContent=req.queryParams("deleteContent")!=null; + if(deleteReportContent){ + if(!"on".equals(req.queryParams("confirmReportContentDeletion"))){ + throw new UserErrorException("Report content deletion not confirmed"); + } + } + }else{ + report=null; + deleteReportContent=false; + } + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + UserBanStatus status=enumValue(req.queryParams("status"), UserBanStatus.class); + UserBanInfo info; + if(status!=UserBanStatus.NONE){ + String message=null; + Instant expiresAt=null; + boolean forcePasswordChange=false; + if(status==UserBanStatus.FROZEN || status==UserBanStatus.SUSPENDED){ + message=req.queryParams("message"); + } + if(status==UserBanStatus.FROZEN){ + expiresAt=Instant.now().plus(safeParseInt(req.queryParams("duration")), ChronoUnit.HOURS); + forcePasswordChange="on".equals(req.queryParams("forcePasswordChange")); + } + info=new UserBanInfo(Instant.now(), expiresAt, message, forcePasswordChange, self.user.id, report==null ? 0 : report.id); }else{ + if(report!=null) + throw new BadRequestException(); + info=null; + } + ctx.getModerationController().setUserBanStatus(self.user, user, user instanceof ForeignUser ? null : ctx.getUsersController().getAccountForUser(user), status, info); + if(report!=null){ + if(deleteReportContent){ + ctx.getModerationController().deleteViolationReportContent(report, Objects.requireNonNull(sessionInfo(req)), false); + } + ctx.getModerationController().resolveViolationReport(report, self.user, status, info); + } + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object deleteAccountImmediatelyForm(Request req, Response resp, Account self, ApplicationContext ctx){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + if(user instanceof ForeignUser || (user.banStatus!=UserBanStatus.SELF_DEACTIVATED && user.banStatus!=UserBanStatus.SUSPENDED)) + throw new BadRequestException(); + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_delete_user_form", req) + .with("user", user) + .with("username", user.username+"@"+Config.domain); + return wrapForm(req, resp, "admin_delete_user_form", "/users/"+user.id+"/deleteImmediately", lang(req).get("admin_user_delete_account_title"), "delete", model); + } + + public static Object deleteAccountImmediately(Request req, Response resp, Account self, ApplicationContext ctx){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + if(user instanceof ForeignUser || (user.banStatus!=UserBanStatus.SELF_DEACTIVATED && user.banStatus!=UserBanStatus.SUSPENDED)) throw new BadRequestException(); + String usernameCheck=user.username+"@"+Config.domain; + if(!usernameCheck.equalsIgnoreCase(req.queryParams("username"))){ + String msg=lang(req).get("admin_user_delete_wrong_username"); + if(isAjax(req)) + return new WebDeltaResponse(resp) + .keepBox() + .show("formMessage_deleteUser") + .setContent("formMessage_deleteUser", msg); + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_delete_user_form", req) + .with("user", user) + .with("username", user.username+"@"+Config.domain) + .with("message", msg); + return wrapForm(req, resp, "admin_delete_user_form", "/users/"+user.id+"/deleteImmediately", lang(req).get("admin_user_delete_account_title"), "delete", model); + } + ctx.getUsersController().deleteLocalUser(self.user, user); + req.session().attribute("adminSettingsUsersMessage", lang(req).get("admin_user_deleted_successfully")); + if(isAjax(req)) + return new WebDeltaResponse(resp).replaceLocation("/settings/admin/users"); + resp.redirect("/settings/admin/users"); + return ""; + } + + public static Object reportsOfUser(Request req, Response resp, Account self, ApplicationContext ctx){ + return userReports(req, resp, self, ctx, true); + } + + public static Object reportsByUser(Request req, Response resp, Account self, ApplicationContext ctx){ + return userReports(req, resp, self, ctx, false); + } + + private static Object userReports(Request req, Response resp, Account self, ApplicationContext ctx, boolean ofUser){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + RenderedTemplateResponse model=new RenderedTemplateResponse("report_list", req); + model.pageTitle(lang(req).get("menu_reports")); + PaginatedList reports; + if(ofUser){ + model.with("tab", "reportsOf"); + reports=ctx.getModerationController().getViolationReportsOfActor(user, offset(req), 50); + }else{ + model.with("tab", "reportsBy"); + reports=ctx.getModerationController().getViolationReportsByUser(user, offset(req), 50); } + model.paginate(reports); + + Set userIDs=reports.list.stream().filter(r->r.targetID>0).map(r->r.targetID).collect(Collectors.toSet()); + userIDs.addAll(reports.list.stream().filter(r->r.reporterID!=0).map(r->r.reporterID).collect(Collectors.toSet())); + Set groupIDs=reports.list.stream().filter(r->r.targetID<0).map(r->-r.targetID).collect(Collectors.toSet()); + + model.with("users", ctx.getUsersController().getUsers(userIDs)) + .with("groups", ctx.getGroupsController().getGroupsByIdAsMap(groupIDs)) + .with("filteredByUser", user); + model.with("staffNoteCount", ctx.getModerationController().getUserStaffNoteCount(user)); - ctx.getModerationController().setViolationReportResolved(report, self.user); + return model; + } + + public static Object userStaffNotes(Request req, Response resp, Account self, ApplicationContext ctx){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_users_notes", req); + PaginatedList notes=ctx.getModerationController().getUserStaffNotes(user, offset(req), 50); + model.paginate(notes); + model.with("users", ctx.getUsersController().getUsers(notes.list.stream().map(ActorStaffNote::authorID).collect(Collectors.toSet()))); + model.with("user", user).with("staffNoteCount", notes.total); + return model; + } + + public static Object userStaffNoteAdd(Request req, Response resp, Account self, ApplicationContext ctx){ + User user=ctx.getUsersController().getUserOrThrow(safeParseInt(req.params(":id"))); + requireQueryParams(req, "text"); + String text=req.queryParams("text"); + ctx.getModerationController().createUserStaffNote(self.user, user, text); if(isAjax(req)) - return new WebDeltaResponse(resp).refresh(); + return new WebDeltaResponse(resp).setContent("commentText", "").refresh(); resp.redirect(back(req)); + return ""; + } + public static Object userStaffNoteConfirmDelete(Request req, Response resp, Account self, ApplicationContext ctx){ + int userID=safeParseInt(req.params(":id")); + int noteID=safeParseInt(req.params(":noteID")); + Lang l=lang(req); + return wrapConfirmation(req, resp, l.get("delete"), l.get("admin_user_staff_note_confirm_delete"), "/users/"+userID+"/staffNotes/"+noteID+"/delete"); + } + + public static Object userStaffNoteDelete(Request req, Response resp, Account self, ApplicationContext ctx){ + int noteID=safeParseInt(req.params(":noteID")); + ctx.getModerationController().deleteUserStaffNote(ctx.getModerationController().getUserStaffNoteOrThrow(noteID)); + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); return ""; } + + public static Object emailDomainRules(Request req, Response resp, Account self, ApplicationContext ctx){ + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_email_rules", req); + List rules=ctx.getModerationController().getEmailDomainBlockRulesFull(); + model.pageTitle(lang(req).get("admin_email_domain_rules")) + .with("rules", rules) + .with("users", ctx.getUsersController().getUsers(rules.stream().map(EmailDomainBlockRuleFull::creatorID).collect(Collectors.toSet()))) + .addMessage(req, "adminEmailRulesMessage"); + return model; + } + + public static Object emailDomainRuleCreateForm(Request req, Response resp, Account self, ApplicationContext ctx){ + return wrapForm(req, resp, "admin_email_rule_form", "/settings/admin/emailRules/create", lang(req).get("admin_email_rule_title"), "create", "adminCreateEmailRule", List.of(), key->null, null); + } + + public static Object emailDomainRuleCreate(Request req, Response resp, Account self, ApplicationContext ctx){ + try{ + String domain=requireFormField(req, "domain", null); + EmailDomainBlockRule.Action action=requireFormField(req, "ruleAction", null, EmailDomainBlockRule.Action.class); + String note=req.queryParams("note"); + ctx.getModerationController().createEmailDomainBlockRule(self.user, domain, action, note); + }catch(UserErrorException x){ + return wrapForm(req, resp, "admin_email_rule_form", "/settings/admin/emailRules/create", lang(req).get("admin_email_rule_title"), "create", "adminCreateEmailRule", + List.of("domain", "ruleAction", "note"), req::queryParams, lang(req).get(x.getMessage())); + } + req.session().attribute("adminEmailRulesMessage", lang(req).get("admin_email_rule_created")); + return ajaxAwareRedirect(req, resp, "/settings/admin/emailRules"); + } + + public static Object emailDomainRuleEdit(Request req, Response resp, Account self, ApplicationContext ctx){ + EmailDomainBlockRuleFull rule=ctx.getModerationController().getEmailDomainBlockRuleOrThrow(req.params(":domain")); + return wrapForm(req, resp, "admin_email_rule_form", "/settings/admin/emailRules/"+rule.rule().domain()+"/update", lang(req).get("admin_email_rule_title"), "save", "adminCreateEmailRule", + List.of("ruleAction", "note"), key->switch(key){ + case "ruleAction" -> rule.rule().action(); + case "note" -> rule.note(); + default -> throw new IllegalStateException("Unexpected value: " + key); + }, null, Map.of("editing", true, "domain", rule.rule().domain())); + } + + public static Object emailDomainRuleUpdate(Request req, Response resp, Account self, ApplicationContext ctx){ + EmailDomainBlockRuleFull rule=ctx.getModerationController().getEmailDomainBlockRuleOrThrow(req.params(":domain")); + try{ + EmailDomainBlockRule.Action action=requireFormField(req, "ruleAction", null, EmailDomainBlockRule.Action.class); + String note=req.queryParams("note"); + ctx.getModerationController().updateEmailDomainBlockRule(self.user, rule, action, note); + }catch(UserErrorException x){ + return wrapForm(req, resp, "admin_email_rule_form", "/settings/admin/emailRules/"+rule.rule().domain()+"/update", lang(req).get("admin_email_rule_title"), "save", "adminCreateEmailRule", + List.of("ruleAction", "note"), key->switch(key){ + case "ruleAction" -> rule.rule().action(); + case "note" -> rule.note(); + default -> throw new IllegalStateException("Unexpected value: " + key); + }, lang(req).get(x.getMessage()), Map.of("editing", true, "domain", rule.rule().domain())); + } + return ajaxAwareRedirect(req, resp, "/settings/admin/emailRules"); + } + + public static Object emailDomainRuleConfirmDelete(Request req, Response resp, Account self, ApplicationContext ctx){ + EmailDomainBlockRuleFull rule=ctx.getModerationController().getEmailDomainBlockRuleOrThrow(req.params(":domain")); + Lang l=lang(req); + return wrapConfirmation(req, resp, l.get("delete"), l.get("admin_confirm_delete_rule"), "/settings/admin/emailRules/"+rule.rule().domain()+"/delete"); + } + + public static Object emailDomainRuleDelete(Request req, Response resp, Account self, ApplicationContext ctx){ + EmailDomainBlockRuleFull rule=ctx.getModerationController().getEmailDomainBlockRuleOrThrow(req.params(":domain")); + ctx.getModerationController().deleteEmailDomainBlockRule(self.user, rule); + return ajaxAwareRedirect(req, resp, "/settings/admin/emailRules"); + } + + public static Object ipRules(Request req, Response resp, Account self, ApplicationContext ctx){ + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_ip_rules", req); + List rules=ctx.getModerationController().getIPBlockRulesFull(); + model.pageTitle(lang(req).get("admin_ip_rules")) + .with("rules", rules) + .with("users", ctx.getUsersController().getUsers(rules.stream().map(IPBlockRuleFull::creatorID).collect(Collectors.toSet()))) + .addMessage(req, "adminIPRulesMessage"); + return model; + } + + public static Object ipRuleCreateForm(Request req, Response resp, Account self, ApplicationContext ctx){ + return wrapForm(req, resp, "admin_ip_rule_form", "/settings/admin/ipRules/create", lang(req).get("admin_ip_rule_title"), "create", "adminCreateIPRule", List.of(), key->null, null); + } + + public static Object ipRuleCreate(Request req, Response resp, Account self, ApplicationContext ctx){ + try{ + InetAddressRange address=InetAddressRange.parse(requireFormField(req, "ipAddress", null)); + if(address==null) + throw new UserErrorException("err_admin_ip_format_invalid"); + IPBlockRule.Action action=requireFormField(req, "ruleAction", null, IPBlockRule.Action.class); + String note=req.queryParams("note"); + int expiry=Math.min(129600, Math.max(60, safeParseInt(requireFormField(req, "expiry", null)))); + ctx.getModerationController().createIPBlockRule(self.user, address, action, expiry, note); + }catch(UserErrorException x){ + return wrapForm(req, resp, "admin_email_rule_form", "/settings/admin/ipRules/create", lang(req).get("admin_ip_rule_title"), "create", "adminCreateIPRule", + List.of("domain", "ruleAction", "note"), req::queryParams, lang(req).get(x.getMessage())); + } + req.session().attribute("adminIPRulesMessage", lang(req).get("admin_ip_rule_created")); + return ajaxAwareRedirect(req, resp, "/settings/admin/ipRules"); + } + + public static Object ipRuleEdit(Request req, Response resp, Account self, ApplicationContext ctx){ + IPBlockRuleFull rule=ctx.getModerationController().getIPBlockRuleFull(safeParseInt(req.params(":id"))); + return wrapForm(req, resp, "admin_ip_rule_form", "/settings/admin/ipRules/"+rule.rule().id()+"/update", lang(req).get("admin_ip_rule_title"), "save", "adminCreateIPRule", + List.of("ruleAction", "note", "expiry"), key->switch(key){ + case "ruleAction" -> rule.rule().action(); + case "note" -> rule.note(); + case "expiry" -> rule.rule().expiresAt(); + default -> throw new IllegalStateException("Unexpected value: " + key); + }, null, Map.of("editing", true, "ipAddress", rule.rule().ipRange().toString())); + } + + public static Object ipRuleUpdate(Request req, Response resp, Account self, ApplicationContext ctx){ + IPBlockRuleFull rule=ctx.getModerationController().getIPBlockRuleFull(safeParseInt(req.params(":id"))); + try{ + IPBlockRule.Action action=requireFormField(req, "ruleAction", null, IPBlockRule.Action.class); + String note=req.queryParams("note"); + int expiry=Math.min(129600, safeParseInt(requireFormField(req, "expiry", null))); + if(expiry<60) + expiry=0; + ctx.getModerationController().updateIPBlockRule(self.user, rule, action, expiry, note); + }catch(UserErrorException x){ + return wrapForm(req, resp, "admin_email_rule_form", "/settings/admin/ipRules/"+rule.rule().id()+"/update", lang(req).get("admin_ip_rule_title"), "save", "adminCreateIPRule", + List.of("ruleAction", "note", "expiry"), key->switch(key){ + case "ruleAction" -> rule.rule().action(); + case "note" -> rule.note(); + case "expiry" -> rule.rule().expiresAt(); + default -> throw new IllegalStateException("Unexpected value: " + key); + }, null, Map.of("editing", true, "ipAddress", rule.rule().ipRange().toString())); + } + return ajaxAwareRedirect(req, resp, "/settings/admin/ipRules"); + } + + public static Object ipRuleConfirmDelete(Request req, Response resp, Account self, ApplicationContext ctx){ + IPBlockRuleFull rule=ctx.getModerationController().getIPBlockRuleFull(safeParseInt(req.params(":id"))); + Lang l=lang(req); + return wrapConfirmation(req, resp, l.get("delete"), l.get("admin_confirm_delete_rule"), "/settings/admin/ipRules/"+rule.rule().id()+"/delete"); + } + + public static Object ipRuleDelete(Request req, Response resp, Account self, ApplicationContext ctx){ + IPBlockRuleFull rule=ctx.getModerationController().getIPBlockRuleFull(safeParseInt(req.params(":id"))); + ctx.getModerationController().deleteIPBlockRule(self.user, rule); + return ajaxAwareRedirect(req, resp, "/settings/admin/ipRules"); + } + + public static Object invites(Request req, Response resp, Account self, ApplicationContext ctx){ + PaginatedList invites=ctx.getModerationController().getAllSignupInvites(offset(req), 100); + Map accounts=ctx.getModerationController().getAccounts(invites.list.stream().map(inv->inv.ownerID).collect(Collectors.toSet())); + Map users=accounts.values().stream().map(a->a.user).collect(Collectors.toMap(u->u.id, Function.identity())); + for(SignupInvitation inv:invites.list){ + if(accounts.containsKey(inv.ownerID)){ + inv.ownerID=accounts.get(inv.ownerID).user.id; + }else{ + inv.ownerID=0; + } + } + RenderedTemplateResponse model=new RenderedTemplateResponse("admin_invites", req) + .paginate(invites) + .with("users", users) + .pageTitle(lang(req).get("admin_invites")) + .addMessage(req, "adminInviteMessage"); + return model; + } + + public static Object confirmDeleteInvite(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + Lang l=lang(req); + return wrapConfirmation(req, resp, l.get("delete"), l.get("confirm_delete_invite"), "/settings/admin/invites/"+id+"/delete"); + } + + public static Object deleteInvite(Request req, Response resp, Account self, ApplicationContext ctx){ + int id=safeParseInt(req.params(":id")); + ctx.getModerationController().deleteSignupInvite(self.user, id); + req.session().attribute("adminInviteMessage", lang(req).get("signup_invite_deleted")); + return ajaxAwareRedirect(req, resp, "/settings/admin/invites"); + } } diff --git a/src/main/java/smithereen/routes/SettingsRoutes.java b/src/main/java/smithereen/routes/SettingsRoutes.java index b795fe78..4fddb57a 100644 --- a/src/main/java/smithereen/routes/SettingsRoutes.java +++ b/src/main/java/smithereen/routes/SettingsRoutes.java @@ -5,11 +5,9 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.sql.SQLException; import java.time.LocalDate; import java.time.ZoneId; @@ -32,17 +30,16 @@ import smithereen.Mailer; import smithereen.Utils; -import smithereen.activitypub.objects.Image; import smithereen.activitypub.objects.LocalImage; import smithereen.model.Account; -import smithereen.model.ForeignGroup; import smithereen.model.Group; import smithereen.model.PrivacySetting; import smithereen.model.SessionInfo; import smithereen.model.SignupInvitation; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.model.UserPrivacySettingKey; +import smithereen.model.UserRole; import smithereen.model.WebDeltaResponse; import smithereen.exceptions.BadRequestException; import smithereen.exceptions.InternalServerErrorException; @@ -51,10 +48,16 @@ import smithereen.exceptions.UserErrorException; import smithereen.lang.Lang; import smithereen.libvips.VipsImage; +import smithereen.model.media.ImageMetadata; +import smithereen.model.media.MediaFileRecord; +import smithereen.model.media.MediaFileReferenceType; +import smithereen.model.media.MediaFileType; import smithereen.storage.GroupStorage; +import smithereen.storage.MediaStorage; import smithereen.storage.MediaStorageUtils; import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; +import smithereen.storage.media.MediaFileStorageDriver; import smithereen.templates.RenderedTemplateResponse; import smithereen.util.FloodControl; import spark.Request; @@ -95,7 +98,7 @@ public static Object createInvite(Request req, Response resp, Account self, Appl if(Config.signupMode==Config.SignupMode.OPEN){ throw new BadRequestException(); } - if(Config.signupMode==Config.SignupMode.CLOSED && self.accessLevel!=Account.AccessLevel.ADMIN) + if(Config.signupMode==Config.SignupMode.CLOSED && !sessionInfo(req).permissions.hasPermission(UserRole.Permission.MANAGE_INVITES)) return wrapError(req, resp, "err_access"); byte[] code=new byte[16]; new Random().nextBytes(code); @@ -106,18 +109,20 @@ public static Object createInvite(Request req, Response resp, Account self, Appl } public static Object updatePassword(Request req, Response resp, Account self, ApplicationContext ctx) throws SQLException{ + requireQueryParams(req, "current", "new", "new2"); String current=req.queryParams("current"); String new1=req.queryParams("new"); String new2=req.queryParams("new2"); String message; if(!new1.equals(new2)){ message=Utils.lang(req).get("err_passwords_dont_match"); - }else if(new1.length()<4){ - message=Utils.lang(req).get("err_password_short"); - }else if(!SessionStorage.updatePassword(self.id, current, new1)){ - message=Utils.lang(req).get("err_old_password_incorrect"); }else{ - message=Utils.lang(req).get("password_changed"); + try{ + ctx.getUsersController().changePassword(self, current, new1); + message=Utils.lang(req).get("password_changed"); + }catch(UserErrorException x){ + message=lang(req).get(x.getMessage()); + } } if(isAjax(req)){ return new WebDeltaResponse(resp).show("formMessage_changePassword").setContent("formMessage_changePassword", message); @@ -182,21 +187,24 @@ public static Object updateProfilePicture(Request req, Response resp, Account se throw new IOException("file too large"); } - byte[] key=MessageDigest.getInstance("MD5").digest((self.user.username+","+System.currentTimeMillis()).getBytes(StandardCharsets.UTF_8)); - String keyHex=Utils.byteArrayToHexString(key); String mime=part.getContentType(); if(!mime.startsWith("image/")) throw new IOException("incorrect mime type"); - File tmpDir = new File(System.getProperty("java.io.tmpdir")); - File temp=new File(tmpDir, keyHex); - part.write(keyHex); + File temp=File.createTempFile("SmithereenUpload", null); + try{ + try(FileOutputStream out=new FileOutputStream(temp)){ + copyBytes(part.getInputStream(), out); + } + }catch(IOException x){ + throw new BadRequestException(x.getMessage(), x); + } VipsImage img=new VipsImage(temp.getAbsolutePath()); if(img.hasAlpha()) img=img.flatten(1, 1, 1); float ratio=(float)img.getWidth()/(float)img.getHeight(); boolean ratioIsValid=ratio<=2.5f && ratio>=0.25f; - LocalImage ava=new LocalImage(); + float[] cropRegion=null; if(ratioIsValid){ try{ String _x1=req.queryParams("x1"), @@ -214,20 +222,20 @@ public static Object updateProfilePicture(Request req, Response resp, Account se int x=Math.round(iw*x1); int y=Math.round(ih*y1); int size=Math.round(((x2-x1)*iw+(y2-y1)*ih)/2f); - ava.cropRegion=new float[]{x1, y1, x2, y2}; + cropRegion=new float[]{x1, y1, x2, y2}; } } }catch(NumberFormatException ignore){} } - if(ava.cropRegion==null && img.getWidth()!=img.getHeight()){ + if(cropRegion==null && img.getWidth()!=img.getHeight()){ int cropSize, cropX=0; if(img.getHeight()>img.getWidth()){ cropSize=img.getWidth(); - ava.cropRegion=new float[]{0f, 0f, 1f, (float)img.getWidth()/(float)img.getHeight()}; + cropRegion=new float[]{0f, 0f, 1f, (float)img.getWidth()/(float)img.getHeight()}; }else{ cropSize=img.getHeight(); cropX=img.getWidth()/2-img.getHeight()/2; - ava.cropRegion=new float[]{(float)cropX/(float)img.getWidth(), 0f, (float)(cropX+img.getHeight())/(float)img.getWidth(), 1f}; + cropRegion=new float[]{(float)cropX/(float)img.getWidth(), 0f, (float)(cropX+img.getHeight())/(float)img.getWidth(), 1f}; } if(!ratioIsValid){ VipsImage cropped=img.crop(cropX, 0, cropSize, cropSize); @@ -236,38 +244,27 @@ public static Object updateProfilePicture(Request req, Response resp, Account se } } - File profilePicsDir=new File(Config.uploadPath, "avatars"); - profilePicsDir.mkdirs(); try{ int[] size={0, 0}; - MediaStorageUtils.writeResizedWebpImage(img, 2560, 0, 93, keyHex, profilePicsDir, size); - ava.localID=keyHex; - ava.path="avatars"; - ava.width=size[0]; - ava.height=size[1]; + File resizedFile=File.createTempFile("SmithereenUploadResized", ".webp"); + MediaStorageUtils.writeResizedWebpImage(img, 2560, 0, 93, resizedFile, size); + ImageMetadata meta=new ImageMetadata(size[0], size[1], null, cropRegion); + MediaFileRecord fileRecord=MediaStorage.createMediaFileRecord(MediaFileType.IMAGE_PHOTO, resizedFile.length(), group==null ? self.user.id : -group.id, meta); + MediaFileStorageDriver.getInstance().storeFile(resizedFile, fileRecord.id()); + LocalImage ava=new LocalImage(); + ava.fileID=fileRecord.id().id(); + ava.fillIn(fileRecord); if(group==null){ - if(self.user.icon!=null){ - LocalImage li=(LocalImage) self.user.icon.get(0); - File file=new File(profilePicsDir, li.localID+".webp"); - if(file.exists()){ - LOG.info("Deleting: {}", file.getAbsolutePath()); - file.delete(); - } - } + MediaStorage.deleteMediaFileReferences(self.user.id, MediaFileReferenceType.USER_AVATAR); UserStorage.updateProfilePicture(self.user, MediaStorageUtils.serializeAttachment(ava).toString()); + MediaStorage.createMediaFileReference(fileRecord.id().id(), self.user.id, MediaFileReferenceType.USER_AVATAR, self.user.id); self.user=UserStorage.getById(self.user.id); ctx.getActivityPubWorker().sendUpdateUserActivity(self.user); }else{ - if(group.icon!=null && !(group instanceof ForeignGroup)){ - LocalImage li=(LocalImage) group.icon.get(0); - File file=new File(profilePicsDir, li.localID+".webp"); - if(file.exists()){ - LOG.info("Deleting: {}", file.getAbsolutePath()); - file.delete(); - } - } + MediaStorage.deleteMediaFileReferences(group.id, MediaFileReferenceType.GROUP_AVATAR); GroupStorage.updateProfilePicture(group, MediaStorageUtils.serializeAttachment(ava).toString()); + MediaStorage.createMediaFileReference(fileRecord.id().id(), group.id, MediaFileReferenceType.GROUP_AVATAR, -group.id); group=GroupStorage.getById(group.id); ctx.getActivityPubWorker().sendUpdateGroupActivity(group); } @@ -280,7 +277,7 @@ public static Object updateProfilePicture(Request req, Response resp, Account se req.session().attribute("settings.profilePicMessage", Utils.lang(req).get("avatar_updated")); resp.redirect("/settings/"); - }catch(IOException|ServletException|NoSuchAlgorithmException|IllegalStateException x){ + }catch(IOException|ServletException|IllegalStateException x){ LOG.error("Exception while processing a profile picture upload", x); if(isAjax(req)){ Lang l=lang(req); @@ -356,16 +353,7 @@ public static Object removeProfilePicture(Request req, Response resp, Account se } } - File profilePicsDir=new File(Config.uploadPath, "avatars"); - List icon=group!=null ? group.icon : self.user.icon; - if(icon!=null && !icon.isEmpty() && icon.get(0) instanceof LocalImage){ - LocalImage li=(LocalImage) icon.get(0); - File file=new File(profilePicsDir, li.localID+".webp"); - if(file.exists()){ - LOG.info("Deleting: {}", file.getAbsolutePath()); - file.delete(); - } - } + MediaStorage.deleteMediaFileReferences(group!=null ? group.id : self.user.id, group!=null ? MediaFileReferenceType.GROUP_AVATAR : MediaFileReferenceType.USER_AVATAR); if(group!=null){ GroupStorage.updateProfilePicture(group, null); @@ -666,4 +654,18 @@ public static Object mobileEditPrivacy(Request req, Response resp, Account self, .with("users", ctx.getUsersController().getUsers(needUsers)) .pageTitle(lang(req).get("privacy_settings_title")); } + + public static Object deactivateAccountForm(Request req, Response resp, Account self, ApplicationContext ctx){ + return wrapForm(req, resp, "deactivate_account_form", "/settings/deactivateAccount", lang(req).get("admin_user_delete_account_title"), "delete", "deactivateAccount", List.of(), null, null); + } + + public static Object deactivateAccount(Request req, Response resp, Account self, ApplicationContext ctx){ + requireQueryParams(req, "password"); + String password=req.queryParams("password"); + if(!ctx.getUsersController().checkPassword(self, password)){ + return wrapForm(req, resp, "deactivate_account_form", "/settings/deactivateAccount", lang(req).get("admin_user_delete_account_title"), "delete", "deactivateAccount", List.of(), null, lang(req).get("err_old_password_incorrect")); + } + ctx.getUsersController().selfDeactivateAccount(self); + return ajaxAwareRedirect(req, resp, "/feed"); + } } diff --git a/src/main/java/smithereen/routes/SystemRoutes.java b/src/main/java/smithereen/routes/SystemRoutes.java index 28cc5701..4108c948 100644 --- a/src/main/java/smithereen/routes/SystemRoutes.java +++ b/src/main/java/smithereen/routes/SystemRoutes.java @@ -7,18 +7,17 @@ import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.sql.SQLException; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Objects; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -27,10 +26,10 @@ import java.util.stream.Collectors; import javax.imageio.ImageIO; + import jakarta.servlet.MultipartConfigElement; import jakarta.servlet.ServletException; import jakarta.servlet.http.Part; - import smithereen.ApplicationContext; import smithereen.BuildInfo; import smithereen.Config; @@ -43,10 +42,17 @@ import smithereen.activitypub.objects.Image; import smithereen.activitypub.objects.LocalImage; import smithereen.activitypub.objects.NoteOrQuestion; +import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.InternalServerErrorException; +import smithereen.exceptions.ObjectNotFoundException; +import smithereen.exceptions.UnsupportedRemoteObjectTypeException; +import smithereen.lang.Lang; +import smithereen.libvips.VipsImage; import smithereen.model.Account; import smithereen.model.ActivityPubRepresentable; import smithereen.model.AttachmentHostContentObject; import smithereen.model.CachedRemoteImage; +import smithereen.model.CaptchaInfo; import smithereen.model.ForeignGroup; import smithereen.model.ForeignUser; import smithereen.model.Group; @@ -55,6 +61,7 @@ import smithereen.model.Poll; import smithereen.model.PollOption; import smithereen.model.Post; +import smithereen.model.ReportableContentObject; import smithereen.model.SearchResult; import smithereen.model.SessionInfo; import smithereen.model.SizedImage; @@ -62,18 +69,19 @@ import smithereen.model.UserInteractions; import smithereen.model.WebDeltaResponse; import smithereen.model.attachments.GraffitiAttachment; -import smithereen.exceptions.BadRequestException; -import smithereen.exceptions.InternalServerErrorException; -import smithereen.exceptions.ObjectNotFoundException; -import smithereen.exceptions.UnsupportedRemoteObjectTypeException; -import smithereen.lang.Lang; -import smithereen.libvips.VipsImage; +import smithereen.model.media.ImageMetadata; +import smithereen.model.media.MediaFileMetadata; +import smithereen.model.media.MediaFileRecord; +import smithereen.model.media.MediaFileType; +import smithereen.model.viewmodel.PostViewModel; import smithereen.storage.GroupStorage; import smithereen.storage.MediaCache; +import smithereen.storage.MediaStorage; import smithereen.storage.MediaStorageUtils; import smithereen.storage.PostStorage; import smithereen.storage.SearchStorage; import smithereen.storage.UserStorage; +import smithereen.storage.media.MediaFileStorageDriver; import smithereen.templates.RenderedTemplateResponse; import smithereen.util.BlurHash; import smithereen.util.CaptchaGenerator; @@ -241,7 +249,8 @@ public static Object downloadExternalMedia(Request req, Response resp) throws SQ return ""; } try{ - if(sessionInfo(req)==null){ // Only download attachments for logged-in users. Prevents crawlers from causing unnecessary churn in the media cache + SessionInfo sessionInfo=sessionInfo(req); + if(sessionInfo==null || sessionInfo.account==null){ // Only download attachments for logged-in users. Prevents crawlers from causing unnecessary churn in the media cache resp.redirect(uri.toString()); return ""; } @@ -271,7 +280,7 @@ public static Object downloadExternalMedia(Request req, Response resp) throws SQ } return ""; }catch(IOException x){ - LOG.warn("Exception while downloading external media file from {}", uri, x); + LOG.debug("Exception while downloading external media file from {}", uri, x); } resp.redirect(uri.toString()); } @@ -284,38 +293,40 @@ public static Object downloadExternalMedia(Request req, Response resp) throws SQ public static Object uploadPostPhoto(Request req, Response resp, Account self, ApplicationContext ctx){ boolean isGraffiti=req.queryParams("graffiti")!=null; - return uploadPhotoAttachment(req, resp, self, isGraffiti, "post_media"); + return uploadPhotoAttachment(req, resp, self, isGraffiti); } public static Object uploadMessagePhoto(Request req, Response resp, Account self, ApplicationContext ctx){ - return uploadPhotoAttachment(req, resp, self, false, "mail_images"); + return uploadPhotoAttachment(req, resp, self, false); } - private static Object uploadPhotoAttachment(Request req, Response resp, Account self, boolean isGraffiti, String dir){ + private static Object uploadPhotoAttachment(Request req, Response resp, Account self, boolean isGraffiti){ + Lang l=lang(req); try{ req.attribute("org.eclipse.jetty.multipartConfig", new MultipartConfigElement(null, 10*1024*1024, -1L, 0)); Part part=req.raw().getPart("file"); if(part.getSize()>10*1024*1024){ resp.status(413); // Payload Too Large - return "File too large"; + return l.get("err_file_upload_too_large", Map.of("maxSize", l.formatFileSize(10*1024*1024))); } - byte[] key=MessageDigest.getInstance("MD5").digest((self.user.username+","+System.currentTimeMillis()+","+part.getSubmittedFileName()).getBytes(StandardCharsets.UTF_8)); - String keyHex=Utils.byteArrayToHexString(key); String mime=part.getContentType(); if(!mime.startsWith("image/")){ resp.status(415); // Unsupported Media Type - return "Unsupported mime type"; + return l.get("err_file_upload_image_format"); } - File tmpDir=new File(System.getProperty("java.io.tmpdir")); - File temp=new File(tmpDir, keyHex); - part.write(keyHex); + File temp=File.createTempFile("SmithereenUpload", null); VipsImage img; try{ + try(FileOutputStream out=new FileOutputStream(temp)){ + copyBytes(part.getInputStream(), out); + } img=new VipsImage(temp.getAbsolutePath()); }catch(IOException x){ - throw new BadRequestException(x.getMessage(), x); + LOG.warn("VipsImage error", x); + resp.status(400); + return l.get("err_file_upload_image_format"); } if(img.hasAlpha()){ VipsImage flat=img.flatten(255, 255, 255); @@ -329,24 +340,17 @@ private static Object uploadPhotoAttachment(Request req, Response resp, Account } LocalImage photo=new LocalImage(); - File postMediaDir=new File(Config.uploadPath, dir); - postMediaDir.mkdirs(); int width, height; + MediaFileRecord fileRecord; try{ + File resizedFile=File.createTempFile("SmithereenUploadResized", ".webp"); int[] outSize={0,0}; - MediaStorageUtils.writeResizedWebpImage(img, 2560, 0, isGraffiti ? MediaStorageUtils.QUALITY_LOSSLESS : 93, keyHex, postMediaDir, outSize); - - SessionInfo sess=Objects.requireNonNull(sessionInfo(req)); - photo.localID=keyHex; - photo.mediaType=isGraffiti ? "image/png" : "image/jpeg"; - photo.path=dir; - photo.width=width=outSize[0]; - photo.height=height=outSize[1]; - photo.blurHash=BlurHash.encode(img, 4, 4); - photo.isGraffiti=isGraffiti; - if(req.queryParams("draft")!=null) - sess.postDraftAttachments.add(photo); - MediaCache.putDraftAttachment(photo, self.id); + MediaStorageUtils.writeResizedWebpImage(img, 2560, 0, isGraffiti ? MediaStorageUtils.QUALITY_LOSSLESS : 93, resizedFile, outSize); + MediaFileMetadata meta=new ImageMetadata(width=outSize[0], height=outSize[1], BlurHash.encode(img, 4, 4), null); + fileRecord=MediaStorage.createMediaFileRecord(isGraffiti ? MediaFileType.IMAGE_GRAFFITI : MediaFileType.IMAGE_PHOTO, resizedFile.length(), self.user.id, meta); + photo.fileID=fileRecord.id().id(); + photo.fillIn(fileRecord); + MediaFileStorageDriver.getInstance().storeFile(resizedFile, fileRecord.id()); temp.delete(); }finally{ @@ -356,7 +360,7 @@ private static Object uploadPhotoAttachment(Request req, Response resp, Account if(isAjax(req)){ resp.type("application/json"); return new JsonObjectBuilder() - .add("id", keyHex) + .add("id", fileRecord.id().getIDForClient()) .add("width", width) .add("height", height) .add("thumbs", new JsonObjectBuilder() @@ -365,33 +369,11 @@ private static Object uploadPhotoAttachment(Request req, Response resp, Account ).build(); } resp.redirect(Utils.back(req)); - }catch(IOException|ServletException|NoSuchAlgorithmException|SQLException x){ - throw new InternalServerErrorException(x); - } - return ""; - } - - public static Object deleteDraftAttachment(Request req, Response resp, Account self, ApplicationContext ctx) throws Exception{ - SessionInfo sess=Utils.sessionInfo(req); - String id=req.queryParams("id"); - if(id==null){ - throw new BadRequestException(); + }catch(IOException|ServletException|SQLException x){ + LOG.error("File upload failed", x); + resp.status(500); + return l.get("err_file_upload"); } - if(MediaCache.deleteDraftAttachment(id, self.id)){ - for(ActivityPubObject o:sess.postDraftAttachments){ - if(o instanceof Document){ - if(id.equals(((Document) o).localID)){ - sess.postDraftAttachments.remove(o); - break; - } - } - } - } - if(isAjax(req)){ - resp.type("application/json"); - return "[]"; - } - resp.redirect(Utils.back(req)); return ""; } @@ -496,6 +478,7 @@ public static Object loadRemoteObject(Request req, Response resp, Account self, try{ uri=ActivityPub.resolveUsername(username, domain); }catch(IOException x){ + LOG.debug("Error getting remote user", x); String error=lang(req).get("remote_object_network_error"); return new JsonObjectBuilder().add("error", error).build(); } @@ -550,6 +533,7 @@ public static Object loadRemoteObject(Request req, Response resp, Account self, return new JsonObjectBuilder().add("success", Config.localURI("/posts/"+posts.get(0).id+"#comment"+nativePost.id).toString()).build(); }catch(InterruptedException ignore){ }catch(ExecutionException e){ + LOG.trace("Error fetching remote object", e); Throwable x=e.getCause(); String error; if(x instanceof UnsupportedRemoteObjectTypeException) @@ -633,16 +617,20 @@ public static Object votePoll(Request req, Response resp, Account self, Applicat ctx.getActivityPubWorker().sendPollVotes(self.user, poll, owner, options, voteIDs); int postID=PostStorage.getPostIdByPollId(id); + Post post; if(postID>0){ - Post post=ctx.getWallController().getPostOrThrow(postID); + post=ctx.getWallController().getPostOrThrow(postID); post.poll=poll; // So the last vote time is as it was before the vote ctx.getWallController().sendUpdateQuestionIfNeeded(post); + }else{ + post=null; } if(isAjax(req)){ UserInteractions interactions=new UserInteractions(); interactions.pollChoices=Arrays.stream(optionIDs).boxed().collect(Collectors.toList()); RenderedTemplateResponse model=new RenderedTemplateResponse("poll", req).with("poll", poll).with("interactions", interactions); + model.with("post", new PostViewModel(post)); return new WebDeltaResponse(resp).setContent("poll"+poll.id, model.renderBlock("inner")); } @@ -724,13 +712,13 @@ public static Object submitReport(Request req, Response resp, Account self, Appl boolean forward="on".equals(req.queryParams("forward")); Actor target; - Object content; + List content; switch(type){ case "post" -> { int id=safeParseInt(rawID); Post post=ctx.getWallController().getPostOrThrow(id); - content=post; + content=List.of(post); target=ctx.getUsersController().getUserOrThrow(post.authorID); } case "user" -> { @@ -747,7 +735,7 @@ public static Object submitReport(Request req, Response resp, Account self, Appl long id=decodeLong(rawID); MailMessage msg=ctx.getMailController().getMessage(self.user, id, false); target=ctx.getUsersController().getUserOrThrow(msg.senderID); - content=msg; + content=List.of(msg); } default -> throw new BadRequestException("invalid type"); } @@ -766,12 +754,12 @@ public static Object captcha(Request req, Response resp) throws IOException{ sid=sid.substring(0, 16); CaptchaGenerator.Captcha c=CaptchaGenerator.generate(); - LruCache captchas=req.session().attribute("captchas"); + LruCache captchas=req.session().attribute("captchas"); if(captchas==null){ captchas=new LruCache<>(10); req.session().attribute("captchas", captchas); } - captchas.put(sid, c.answer()); + captchas.put(sid, new CaptchaInfo(c.answer(), Instant.now())); resp.type("image/png"); ByteArrayOutputStream out=new ByteArrayOutputStream(); diff --git a/src/main/java/smithereen/sparkext/AdminRouteAdapter.java b/src/main/java/smithereen/sparkext/AdminRouteAdapter.java index c3172830..0c059a84 100644 --- a/src/main/java/smithereen/sparkext/AdminRouteAdapter.java +++ b/src/main/java/smithereen/sparkext/AdminRouteAdapter.java @@ -1,9 +1,8 @@ package smithereen.sparkext; -import smithereen.ApplicationContext; import smithereen.Utils; -import smithereen.model.Account; import smithereen.model.SessionInfo; +import smithereen.model.UserRole; import spark.Request; import spark.Response; import spark.Route; @@ -11,12 +10,12 @@ /*package*/ class AdminRouteAdapter implements Route{ private final LoggedInRoute target; - private final Account.AccessLevel requiredAccessLevel; + private final UserRole.Permission permission; private final boolean needCSRF; - public AdminRouteAdapter(LoggedInRoute target, Account.AccessLevel requiredAccessLevel, boolean needCSRF){ + public AdminRouteAdapter(LoggedInRoute target, UserRole.Permission permission, boolean needCSRF){ this.target=target; - this.requiredAccessLevel=requiredAccessLevel; + this.permission=permission; this.needCSRF=needCSRF; } @@ -25,13 +24,9 @@ public Object handle(Request request, Response response) throws Exception{ if(!Utils.requireAccount(request, response) || (needCSRF && !Utils.verifyCSRF(request, response))) return ""; SessionInfo info=Utils.sessionInfo(request); - return handle(request, response, info.account, Utils.context(request)); - } - - private Object handle(Request req, Response resp, Account self, ApplicationContext ctx) throws Exception{ - if(self.accessLevel.ordinal(){ + conn.createStatement().execute(""" + CREATE TABLE `user_roles` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + `permissions` varbinary(255) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"""); + insertDefaultRoles(conn); + conn.createStatement().execute(""" + ALTER TABLE accounts ADD `role` int unsigned DEFAULT NULL, + ADD CONSTRAINT `accounts_ibfk_2` FOREIGN KEY (`role`) REFERENCES `user_roles` (`id`) ON DELETE SET NULL, + ADD `promoted_by` int unsigned DEFAULT NULL, + ADD CONSTRAINT `accounts_ibfk_3` FOREIGN KEY (`promoted_by`) REFERENCES `accounts` (`id`) ON DELETE SET NULL"""); + new SQLQueryBuilder(conn) + .update("accounts") + .where("access_level=2") // moderator -> new moderator role + .value("role", 3) + .executeNoResult(); + new SQLQueryBuilder(conn) + .update("accounts") + .where("access_level=3") // admin -> new admin role + .value("role", 2) + .executeNoResult(); + new SQLQueryBuilder(conn) + .update("accounts") + .where("access_level=3 AND id=1") // first admin -> new owner role + .value("role", 1) + .executeNoResult(); + conn.createStatement().execute("ALTER TABLE accounts DROP access_level"); + } + case 35 -> { + conn.createStatement().execute(""" + CREATE TABLE `audit_log` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `admin_id` int unsigned NOT NULL, + `action` int unsigned NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `owner_id` int DEFAULT NULL, + `object_id` bigint DEFAULT NULL, + `object_type` int unsigned DEFAULT NULL, + `extra` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `owner_id` (`owner_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"""); + } + case 36 -> { + conn.createStatement().execute("ALTER TABLE sessions DROP last_ip, ADD `ip` binary(16) NOT NULL, ADD `user_agent` bigint NOT NULL"); + conn.createStatement().execute(""" + CREATE TABLE `user_agents` ( + `hash` bigint NOT NULL, + `user_agent` text COLLATE utf8mb4_general_ci NOT NULL, + PRIMARY KEY (`hash`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"""); + } + case 37 -> { + conn.createStatement().execute("ALTER TABLE accounts ADD email_domain varchar(150) NOT NULL DEFAULT '', ADD INDEX (email_domain), DROP ban_info, ADD last_ip binary(16) DEFAULT NULL, ADD INDEX (last_ip)"); + conn.createStatement().execute("UPDATE accounts SET email_domain=SUBSTR(email, LOCATE('@', email)+1)"); + conn.createStatement().execute("ALTER TABLE `users` ADD ban_status tinyint unsigned NOT NULL DEFAULT 0, ADD INDEX (ban_status), ADD ban_info json DEFAULT NULL"); + } + case 38 -> { + conn.createStatement().execute(""" + CREATE TABLE IF NOT EXISTS `media_files` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `random_id` binary(18) NOT NULL, + `size` bigint unsigned NOT NULL, + `type` tinyint unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `metadata` json NOT NULL, + `ref_count` int unsigned NOT NULL DEFAULT '0', + `original_owner_id` int NOT NULL, + PRIMARY KEY (`id`), + KEY `ref_count` (`ref_count`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"""); + conn.createStatement().execute(""" + CREATE TABLE IF NOT EXISTS `media_file_refs` ( + `file_id` bigint unsigned NOT NULL, + `object_id` bigint NOT NULL, + `object_type` tinyint unsigned NOT NULL, + `owner_user_id` int unsigned DEFAULT NULL, + `owner_group_id` int unsigned DEFAULT NULL, + PRIMARY KEY (`object_id`,`object_type`,`file_id`), + KEY `file_id` (`file_id`), + KEY `owner_user_id` (`owner_user_id`), + KEY `owner_group_id` (`owner_group_id`), + CONSTRAINT `media_file_refs_ibfk_1` FOREIGN KEY (`file_id`) REFERENCES `media_files` (`id`), + CONSTRAINT `media_file_refs_ibfk_2` FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `media_file_refs_ibfk_3` FOREIGN KEY (`owner_group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"""); + conn.createStatement().execute("DROP TABLE IF EXISTS `draft_attachments`"); + createMediaRefCountTriggers(conn); + } + case 39 -> migrateMediaFiles(conn); + case 40 -> { + conn.createStatement().execute("ALTER TABLE `reports` ADD `content` json DEFAULT NULL, ADD `state` tinyint unsigned NOT NULL DEFAULT 0, ADD KEY `state` (`state`), CHANGE `target_id` `target_id` int NOT NULL, ADD KEY `target_id` (`target_id`)"); + conn.createStatement().execute(""" + CREATE TABLE `report_actions` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `report_id` int unsigned NOT NULL, + `user_id` int unsigned NOT NULL, + `action_type` tinyint unsigned NOT NULL, + `text` text COLLATE utf8mb4_general_ci, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `extra` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `report_id` (`report_id`), + CONSTRAINT `report_actions_ibfk_1` FOREIGN KEY (`report_id`) REFERENCES `reports` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;"""); + } + case 41 -> { + conn.createStatement().execute(""" + CREATE TABLE `user_staff_notes` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `target_id` int unsigned NOT NULL, + `author_id` int unsigned NOT NULL, + `text` text COLLATE utf8mb4_general_ci NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `target_id` (`target_id`), + CONSTRAINT `user_staff_notes_ibfk_1` FOREIGN KEY (`target_id`) REFERENCES `users` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;"""); + } + case 42 -> { + conn.createStatement().execute(""" + CREATE TABLE `blocks_email_domain` ( + `domain` varchar(100) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `action` tinyint unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `note` text COLLATE utf8mb4_general_ci NOT NULL, + `creator_id` int unsigned NOT NULL, + PRIMARY KEY (`domain`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;"""); + conn.createStatement().execute(""" + CREATE TABLE `blocks_ip` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `address` binary(16) NOT NULL, + `prefix_length` tinyint unsigned NOT NULL, + `action` tinyint unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires_at` timestamp NOT NULL, + `note` text COLLATE utf8mb4_general_ci NOT NULL, + `creator_id` int unsigned NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;"""); + } + case 43 -> { + conn.createStatement().execute("DROP FUNCTION `bin_prefix`"); + conn.createStatement().execute(""" + CREATE FUNCTION `bin_prefix`(p VARBINARY(1024)) RETURNS varbinary(2048) DETERMINISTIC + RETURN CONCAT(REPLACE(REPLACE(REPLACE(p, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%');"""); + } + } + } + + private static void insertDefaultRoles(DatabaseConnection conn) throws SQLException{ + new SQLQueryBuilder(conn) + .insertInto("user_roles") + .value("name", "Owner") + .value("permissions", Utils.serializeEnumSetToBytes(EnumSet.of(UserRole.Permission.SUPERUSER, UserRole.Permission.VISIBLE_IN_STAFF))) + .executeNoResult(); + + EnumSet adminPermissions=EnumSet.allOf(UserRole.Permission.class); + adminPermissions.remove(UserRole.Permission.SUPERUSER); + new SQLQueryBuilder(conn) + .insertInto("user_roles") + .value("name", "Admin") + .value("permissions", Utils.serializeEnumSetToBytes(adminPermissions)) + .executeNoResult(); + + EnumSet moderatorPermissions=EnumSet.of( + UserRole.Permission.MANAGE_USERS, + UserRole.Permission.MANAGE_REPORTS, + UserRole.Permission.VIEW_SERVER_AUDIT_LOG, + UserRole.Permission.MANAGE_GROUPS + ); + new SQLQueryBuilder(conn) + .insertInto("user_roles") + .value("name", "Moderator") + .value("permissions", Utils.serializeEnumSetToBytes(moderatorPermissions)) + .executeNoResult(); + Config.reloadRoles(); + } + + private static void createMediaRefCountTriggers(DatabaseConnection conn) throws SQLException{ + conn.createStatement().execute("CREATE TRIGGER inc_count_on_insert AFTER INSERT ON media_file_refs FOR EACH ROW UPDATE media_files SET ref_count=ref_count+1 WHERE id=NEW.file_id"); + conn.createStatement().execute("CREATE TRIGGER dec_count_on_delete AFTER DELETE ON media_file_refs FOR EACH ROW UPDATE media_files SET ref_count=ref_count-1 WHERE id=OLD.file_id"); + } + + private static void migrateMediaFiles(DatabaseConnection conn) throws SQLException{ + LOG.info("Started migrating user avatars"); + try(ResultSet res=new SQLQueryBuilder(conn) + .selectFrom("users") + .columns("id", "avatar") + .where("domain='' AND avatar IS NOT NULL") + .execute()){ + while(res.next()){ + int id=res.getInt(1); + JsonObject avaObj=JsonParser.parseString(res.getString(2)).getAsJsonObject(); + if(!avaObj.has("_lid")) + continue; + long newID=migrateOneAvatar(conn, avaObj, id); + if(newID==0) + continue; + new SQLQueryBuilder(conn) + .insertInto("media_file_refs") + .value("file_id", newID) + .value("object_id", id) + .value("object_type", MediaFileReferenceType.USER_AVATAR) + .value("owner_user_id", id) + .executeNoResult(); + new SQLQueryBuilder(conn) + .update("users") + .value("avatar", new JsonObjectBuilder().add("type", "_LocalImage").add("_fileID", newID).build().toString()) + .where("id=?", id) + .executeNoResult(); + } + } + LOG.info("Started migrating group avatars"); + try(ResultSet res=new SQLQueryBuilder(conn) + .selectFrom("groups") + .columns("id", "avatar") + .where("domain='' AND avatar IS NOT NULL") + .execute()){ + while(res.next()){ + int id=res.getInt(1); + JsonObject avaObj=JsonParser.parseString(res.getString(2)).getAsJsonObject(); + if(!avaObj.has("_lid")) + continue; + long newID=migrateOneAvatar(conn, avaObj, -id); + if(newID==0) + continue; + new SQLQueryBuilder(conn) + .insertInto("media_file_refs") + .value("file_id", newID) + .value("object_id", id) + .value("object_type", MediaFileReferenceType.GROUP_AVATAR) + .value("owner_group_id", id) + .executeNoResult(); + new SQLQueryBuilder(conn) + .update("groups") + .value("avatar", new JsonObjectBuilder().add("type", "_LocalImage").add("_fileID", newID).build().toString()) + .where("id=?", id) + .executeNoResult(); + } + } + LOG.info("Started migrating wall attachments"); + try(ResultSet res=new SQLQueryBuilder(conn) + .selectFrom("wall_posts") + .columns("id", "owner_user_id", "owner_group_id", "attachments") + .where("ap_id IS NULL AND attachments IS NOT NULL") + .execute()){ + while(res.next()){ + int id=res.getInt(1); + int ownerID=res.getInt(2); + if(res.wasNull()) + ownerID=-res.getInt(3); + JsonElement _attachments=JsonParser.parseString(res.getString(4)); + List attachments; + if(_attachments instanceof JsonObject jo){ + attachments=List.of(jo); + }else if(_attachments instanceof JsonArray ja){ + attachments=new ArrayList<>(ja.size()); + for(JsonElement el:ja) + attachments.add(el.getAsJsonObject()); + }else{ + throw new IllegalStateException(); + } + if(!attachments.getFirst().has("_p")) + continue; + long[] attachmentIDs=migrateMediaAttachments(conn, attachments, ownerID); + JsonArray newAttachments=new JsonArray(); + for(long attID:attachmentIDs){ + newAttachments.add(new JsonObjectBuilder() + .add("type", "_LocalImage") + .add("_fileID", attID) + .build()); + if(attID==0) + continue; + new SQLQueryBuilder(conn) + .insertInto("media_file_refs") + .value("file_id", attID) + .value("object_id", id) + .value("object_type", MediaFileReferenceType.WALL_ATTACHMENT) + .value(ownerID>0 ? "owner_user_id" : "owner_group_id", Math.abs(ownerID)) + .executeNoResult(); + } + new SQLQueryBuilder(conn) + .update("wall_posts") + .value("attachments", (newAttachments.size()==1 ? newAttachments.get(0) : newAttachments).toString()) + .where("id=?", id) + .executeNoResult(); + } + } + LOG.info("Started migrating mail attachments"); + try(ResultSet res=new SQLQueryBuilder(conn) + .selectFrom("mail_messages") + .columns("id", "owner_id", "attachments") + .where("ap_id IS NULL AND attachments IS NOT NULL") + .execute()){ + while(res.next()){ + long id=res.getInt(1); + int ownerID=res.getInt(2); + JsonElement _attachments=JsonParser.parseString(res.getString(3)); + List attachments; + if(_attachments instanceof JsonObject jo){ + attachments=List.of(jo); + }else if(_attachments instanceof JsonArray ja){ + attachments=new ArrayList<>(ja.size()); + for(JsonElement el:ja) + attachments.add(el.getAsJsonObject()); + }else{ + throw new IllegalStateException(); + } + if(!attachments.getFirst().has("_p")) + continue; + long[] attachmentIDs=migrateMediaAttachments(conn, attachments, ownerID); + JsonArray newAttachments=new JsonArray(); + for(long attID:attachmentIDs){ + newAttachments.add(new JsonObjectBuilder() + .add("type", "_LocalImage") + .add("_fileID", attID) + .build()); + if(attID==0) + continue; + new SQLQueryBuilder(conn) + .insertInto("media_file_refs") + .value("file_id", attID) + .value("object_id", id) + .value("object_type", MediaFileReferenceType.MAIL_ATTACHMENT) + .value("owner_user_id", ownerID) + .executeNoResult(); + } + new SQLQueryBuilder(conn) + .update("mail_messages") + .value("attachments", (newAttachments.size()==1 ? newAttachments.get(0) : newAttachments).toString()) + .where("id=?", id) + .executeNoResult(); + } + } + LOG.info("Media file migration done"); + } + + private static long migrateOneAvatar(DatabaseConnection conn, JsonObject avaObj, int id) throws SQLException{ + String fileID=avaObj.get("_lid").getAsString(); + String dirName=avaObj.get("_p").getAsString(); + + File actualFile=new File(Config.uploadPath, dirName+"/"+fileID+".webp"); + if(!actualFile.exists()){ + LOG.debug("Skipping file {} because it does not exist on disk", actualFile.getAbsolutePath()); + return 0; } + + int width=avaObj.getAsJsonArray("_sz").get(0).getAsInt(); + int height=avaObj.getAsJsonArray("_sz").get(1).getAsInt(); + JsonArray _cropRegion=avaObj.getAsJsonArray("cropRegion"); + float[] cropRegion=new float[4]; + for(int i=0;i<4;i++) + cropRegion[i]=_cropRegion.get(i).getAsFloat(); + ImageMetadata meta=new ImageMetadata(width, height, null, cropRegion); + byte[] randomID=Utils.randomBytes(18); + long newID=new SQLQueryBuilder(conn) + .insertInto("media_files") + .value("random_id", randomID) + .value("size", actualFile.length()) + .value("type", MediaFileType.IMAGE_AVATAR) + .value("metadata", Utils.gson.toJson(meta)) + .value("original_owner_id", id) + .executeAndGetIDLong(); + int oid=Math.abs(id); + File newFileDir=new File(Config.uploadPath, String.format(Locale.US, "%02d/%02d/%02d", oid%100, oid/100%100, oid/100_00%100)); + if(!newFileDir.exists() && !newFileDir.mkdirs()) + throw new RuntimeException("mkdirs failed"); + File newFile=new File(newFileDir, Base64.getUrlEncoder().withoutPadding().encodeToString(randomID)+"_" + +Base64.getUrlEncoder().withoutPadding().encodeToString(Utils.packLong(XTEA.obfuscateObjectID(newID, ObfuscatedObjectIDType.MEDIA_FILE)))+".webp"); + try{ + LOG.debug("Copying: {} -> {}", actualFile.getAbsolutePath(), newFile.getAbsolutePath()); + Files.copy(actualFile.toPath(), newFile.toPath()); + }catch(IOException x){ + throw new RuntimeException("failed to copy file", x); + } + return newID; + } + + private static long[] migrateMediaAttachments(DatabaseConnection conn, List attachments, int ownerID) throws SQLException{ + long[] ids=new long[attachments.size()]; + int i=0; + for(JsonObject obj:attachments){ + String fileID=obj.get("_lid").getAsString(); + String dirName=obj.get("_p").getAsString(); + + File actualFile=new File(Config.uploadPath, dirName+"/"+fileID+".webp"); + if(!actualFile.exists()){ + LOG.debug("Skipping file {} because it does not exist on disk", actualFile.getAbsolutePath()); + i++; + continue; + } + int width=obj.getAsJsonArray("_sz").get(0).getAsInt(); + int height=obj.getAsJsonArray("_sz").get(1).getAsInt(); + String blurhash=obj.has("blurhash") ? obj.get("blurhash").getAsString() : null; + ImageMetadata meta=new ImageMetadata(width, height, blurhash, null); + byte[] randomID=Utils.randomBytes(18); + boolean isGraffiti=obj.has("graffiti") && obj.get("graffiti").getAsBoolean(); + long newID=new SQLQueryBuilder(conn) + .insertInto("media_files") + .value("random_id", randomID) + .value("size", actualFile.length()) + .value("type", isGraffiti ? MediaFileType.IMAGE_GRAFFITI : MediaFileType.IMAGE_PHOTO) + .value("metadata", Utils.gson.toJson(meta)) + .value("original_owner_id", ownerID) + .executeAndGetIDLong(); + int oid=Math.abs(ownerID); + File newFileDir=new File(Config.uploadPath, String.format(Locale.US, "%02d/%02d/%02d", oid%100, oid/100%100, oid/100_00%100)); + if(!newFileDir.exists() && !newFileDir.mkdirs()) + throw new RuntimeException("mkdirs failed"); + File newFile=new File(newFileDir, Base64.getUrlEncoder().withoutPadding().encodeToString(randomID)+"_" + +Base64.getUrlEncoder().withoutPadding().encodeToString(Utils.packLong(XTEA.obfuscateObjectID(newID, ObfuscatedObjectIDType.MEDIA_FILE)))+".webp"); + try{ + LOG.debug("Copying: {} -> {}", actualFile.getAbsolutePath(), newFile.getAbsolutePath()); + Files.copy(actualFile.toPath(), newFile.toPath()); + }catch(IOException x){ + throw new RuntimeException("failed to copy file", x); + } + ids[i]=newID; + i++; + } + + return ids; } } diff --git a/src/main/java/smithereen/storage/DatabaseUtils.java b/src/main/java/smithereen/storage/DatabaseUtils.java index 20a601d8..bda628c8 100644 --- a/src/main/java/smithereen/storage/DatabaseUtils.java +++ b/src/main/java/smithereen/storage/DatabaseUtils.java @@ -99,7 +99,7 @@ public static int insertAndGetID(PreparedStatement stmt) throws SQLException{ } } - public static IntStream intResultSetToStream(ResultSet res) throws SQLException{ + public static IntStream intResultSetToStream(ResultSet res, Runnable close) throws SQLException{ try{ return StreamSupport.intStream(new Spliterators.AbstractIntSpliterator(Long.MAX_VALUE, Spliterator.ORDERED){ @Override @@ -110,6 +110,8 @@ public boolean tryAdvance(IntConsumer action){ return true; } res.close(); + if(close!=null) + close.run(); }catch(SQLException x){ throw new UncheckedSQLException(x); } @@ -123,6 +125,8 @@ public void forEachRemaining(IntConsumer action){ action.accept(res.getInt(1)); } res.close(); + if(close!=null) + close.run(); }catch(SQLException x){ throw new UncheckedSQLException(x); } diff --git a/src/main/java/smithereen/storage/GroupStorage.java b/src/main/java/smithereen/storage/GroupStorage.java index 2e9e1584..c6195f0a 100644 --- a/src/main/java/smithereen/storage/GroupStorage.java +++ b/src/main/java/smithereen/storage/GroupStorage.java @@ -4,6 +4,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.net.URI; import java.security.KeyPair; @@ -11,6 +13,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; @@ -23,6 +26,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -31,6 +35,7 @@ import smithereen.LruCache; import smithereen.Utils; import smithereen.activitypub.SerializerContext; +import smithereen.activitypub.objects.LocalImage; import smithereen.controllers.GroupsController; import smithereen.model.ForeignGroup; import smithereen.model.Group; @@ -39,12 +44,15 @@ import smithereen.model.PaginatedList; import smithereen.model.User; import smithereen.model.UserNotifications; +import smithereen.model.media.MediaFileRecord; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; import smithereen.storage.sql.SQLQueryBuilder; +import smithereen.storage.utils.IntPair; import spark.utils.StringUtils; public class GroupStorage{ + private static final Logger LOG=LoggerFactory.getLogger(GroupStorage.class); private static final LruCache cacheByID=new LruCache<>(500); private static final LruCache cacheByUsername=new LruCache<>(500); @@ -212,6 +220,28 @@ public static synchronized void putOrUpdateForeignGroup(ForeignGroup group) thro } } } + }catch(SQLIntegrityConstraintViolationException x){ + // Rare case: group with a matching username@domain but a different AP ID already exists + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int oldID=new SQLQueryBuilder(conn) + .selectFrom("groups") + .columns("id") + .where("username=? AND domain=? AND ap_id<>?", group.username, group.domain, group.activityPubID) + .executeAndGetInt(); + if(oldID<=0){ + LOG.warn("Didn't find an existing group with username {}@{} while trying to deduplicate {}", group.username, group.domain, group.activityPubID); + throw x; + } + LOG.info("Deduplicating group rows: username {}@{}, old local ID {}, new AP ID {}", group.username, group.domain, oldID, group.activityPubID); + // Assign a temporary random username to this existing user row to get it out of the way + new SQLQueryBuilder(conn) + .update("groups") + .value("username", UUID.randomUUID().toString()) + .where("id=?", oldID) + .executeNoResult(); + // Try again + putOrUpdateForeignGroup(group); + } } } @@ -224,8 +254,14 @@ public static synchronized Group getById(int id) throws SQLException{ .allColumns() .where("id=?", id) .executeAndGetSingleObject(Group::fromResultSet); - if(g!=null) + if(g!=null){ + if(g.icon!=null && !g.icon.isEmpty() && g.icon.getFirst() instanceof LocalImage li){ + MediaFileRecord mfr=MediaStorage.getMediaFileRecord(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } putIntoCache(g); + } return g; } @@ -246,8 +282,14 @@ public static synchronized Group getByUsername(String username) throws SQLExcept .allColumns() .where("username=? AND domain=?", username, domain) .executeAndGetSingleObject(Group::fromResultSet); - if(g!=null) + if(g!=null){ + if(g.icon!=null && !g.icon.isEmpty() && g.icon.getFirst() instanceof LocalImage li){ + MediaFileRecord mfr=MediaStorage.getMediaFileRecord(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } putIntoCache(g); + } return g; } @@ -329,6 +371,21 @@ public static Map getById(Collection _ids) throws SQLEx return group; }) .collect(Collectors.toMap(g->g.id, Function.identity()))); + Set needAvatars=result.values().stream() + .map(g->g.icon!=null && !g.icon.isEmpty() && g.icon.getFirst() instanceof LocalImage li ? li : null) + .filter(li->li!=null && li.fileRecord==null) + .map(li->li.fileID) + .collect(Collectors.toSet()); + if(!needAvatars.isEmpty()){ + Map records=MediaStorage.getMediaFileRecords(needAvatars); + for(Group group:result.values()){ + if(group.icon!=null && !group.icon.isEmpty() && group.icon.getFirst() instanceof LocalImage li && li.fileRecord==null){ + MediaFileRecord mfr=records.get(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } + } + } synchronized(GroupStorage.class){ for(int id:ids){ putIntoCache(result.get(id)); @@ -874,15 +931,15 @@ public static int putInvitation(int groupID, int inviterID, int inviteeID, boole } public static List getUserInvitations(int userID, boolean isEvent, int offset, int count) throws SQLException{ - List ids=new SQLQueryBuilder() + List ids=new SQLQueryBuilder() .selectFrom("group_invites") .columns("group_id", "inviter_id") .where("invitee_id=? AND is_event=?", userID, isEvent) .limit(count, offset) - .executeAsStream(r->new IdPair(r.getInt(1), r.getInt(2))) + .executeAsStream(r->new IntPair(r.getInt(1), r.getInt(2))) .toList(); - Set needGroups=ids.stream().map(IdPair::first).collect(Collectors.toSet()); - Set needUsers=ids.stream().map(IdPair::second).collect(Collectors.toSet()); + Set needGroups=ids.stream().map(IntPair::first).collect(Collectors.toSet()); + Set needUsers=ids.stream().map(IntPair::second).collect(Collectors.toSet()); Map groups=getById(needGroups); Map users=UserStorage.getById(needUsers); // All groups and users must exist, this is taken care of by schema constraints @@ -894,7 +951,7 @@ public static URI getInvitationApID(int userID, int groupID) throws SQLException .selectFrom("group_invites") .columns("ap_id") .where("invitee_id=? AND group_id=?", userID, groupID) - .executeAndGetSingleObject(r->URI.create(r.getString(1))); + .executeAndGetSingleObject(r->r.getString(1)==null ? null : URI.create(r.getString(1))); } public static int deleteInvitation(int userID, int groupID, boolean isEvent) throws SQLException{ diff --git a/src/main/java/smithereen/storage/IdPair.java b/src/main/java/smithereen/storage/IdPair.java deleted file mode 100644 index efaeb7f7..00000000 --- a/src/main/java/smithereen/storage/IdPair.java +++ /dev/null @@ -1,4 +0,0 @@ -package smithereen.storage; - -record IdPair(int first, int second){ -} diff --git a/src/main/java/smithereen/storage/MailStorage.java b/src/main/java/smithereen/storage/MailStorage.java index 89f869f8..2d6d9bb0 100644 --- a/src/main/java/smithereen/storage/MailStorage.java +++ b/src/main/java/smithereen/storage/MailStorage.java @@ -14,10 +14,13 @@ import java.util.stream.Collectors; import smithereen.Utils; +import smithereen.activitypub.objects.ActivityPubObject; +import smithereen.activitypub.objects.LocalImage; import smithereen.model.MailMessage; import smithereen.model.MessagesPrivacyGrant; import smithereen.model.ObfuscatedObjectIDType; import smithereen.model.PaginatedList; +import smithereen.model.media.MediaFileRecord; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; import smithereen.storage.sql.SQLQueryBuilder; @@ -41,6 +44,7 @@ public static PaginatedList getInbox(int ownerID, int offset, int c .orderBy("created_at DESC") .executeAsStream(MailMessage::fromResultSet) .toList(); + postprocessMessages(messages); return new PaginatedList<>(messages, total, offset, count); } } @@ -62,11 +66,12 @@ public static PaginatedList getOutbox(int ownerID, int offset, int .orderBy("created_at DESC") .executeAsStream(MailMessage::fromResultSet) .toList(); + postprocessMessages(messages); return new PaginatedList<>(messages, total, offset, count); } } - public static long createMessage(String text, String subject, String attachments, int senderID, Set to, Set cc, Set localOwners, URI apID, Map replyInfos) throws SQLException{ + public static long createMessage(String text, String subject, String attachments, int senderID, Set to, Set cc, Set localOwners, URI apID, Map replyInfos, Map allIDs) throws SQLException{ try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ long[] _id={0}; DatabaseUtils.doWithTransaction(conn, ()->{ @@ -89,6 +94,8 @@ public static long createMessage(String text, String subject, String attachments .value("ap_id", Objects.toString(apID, null)) .value("reply_info", replyInfo) .executeAndGetIDLong(); + if(allIDs!=null) + allIDs.put(ownerID, id); if(ownerID==senderID) _id[0]=id; messageIDs.add(id); @@ -160,10 +167,12 @@ public static MailMessage getMessage(int ownerID, long messageID, boolean wantDe String where="id=? AND owner_id=?"; if(!wantDeleted) where+=" AND deleted_at IS NULL"; - return new SQLQueryBuilder() + MailMessage msg=new SQLQueryBuilder() .selectFrom("mail_messages") .where(where, XTEA.deobfuscateObjectID(messageID, ObfuscatedObjectIDType.MAIL_MESSAGE), ownerID) .executeAndGetSingleObject(MailMessage::fromResultSet); + postprocessMessages(Set.of(msg)); + return msg; } public static void addMessageReadReceipt(int ownerID, Collection messageIDs, int readByUserID) throws SQLException{ @@ -183,39 +192,37 @@ public static int getUnreadMessagesCount(int ownerID) throws SQLException{ .executeAndGetInt(); } - public static int getMessageRefCount(Collection relatedIDs) throws SQLException{ - return new SQLQueryBuilder() - .selectFrom("mail_messages") - .count() - .whereIn("id", relatedIDs.stream().map(id->XTEA.deobfuscateObjectID(id, ObfuscatedObjectIDType.MAIL_MESSAGE)).collect(Collectors.toSet())) - .executeAndGetInt(); - } - public static List getMessages(Collection ids) throws SQLException{ - return new SQLQueryBuilder() + List msgs=new SQLQueryBuilder() .selectFrom("mail_messages") .allColumns() .whereIn("id", ids.stream().map(id->XTEA.deobfuscateObjectID(id, ObfuscatedObjectIDType.MAIL_MESSAGE)).collect(Collectors.toSet())) .executeAsStream(MailMessage::fromResultSet) .toList(); + postprocessMessages(msgs); + return msgs; } public static List getRecentlyDeletedMessages(Instant before) throws SQLException{ - return new SQLQueryBuilder() + List msgs=new SQLQueryBuilder() .selectFrom("mail_messages") .allColumns() .where("deleted_at IS NOT NULL AND deleted_at getMessages(URI apID) throws SQLException{ - return new SQLQueryBuilder() + List msgs=new SQLQueryBuilder() .selectFrom("mail_messages") .allColumns() .where("ap_id=?", apID) .executeAsStream(MailMessage::fromResultSet) .toList(); + postprocessMessages(msgs); + return msgs; } public static void createOrRenewPrivacyGrant(int ownerID, int userID, int msgCount) throws SQLException{ @@ -254,15 +261,41 @@ public static PaginatedList getHistory(int ownerID, int peerID, int stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT mail_messages.* FROM mail_messages_peers JOIN mail_messages ON message_id=mail_messages.id" + " WHERE mail_messages_peers.owner_id=? AND mail_messages_peers.peer_id=? AND mail_messages.deleted_at IS NULL ORDER BY message_id DESC LIMIT ? OFFSET ?", ownerID, peerID, count, offset); List msgs=DatabaseUtils.resultSetToObjectStream(stmt.executeQuery(), MailMessage::fromResultSet, null).toList(); + postprocessMessages(msgs); return new PaginatedList<>(msgs, total, offset, count); } } public static Map getMessagesAsModerator(Collection ids) throws SQLException{ - return new SQLQueryBuilder() + Map msgs=new SQLQueryBuilder() .selectFrom("mail_messages") - .whereIn("id", ids.stream().map(i->XTEA.deobfuscateObjectID(i, ObfuscatedObjectIDType.MAIL_MESSAGE)).collect(Collectors.toSet())) + .whereIn("id", ids) .executeAsStream(MailMessage::fromResultSet) - .collect(Collectors.toMap(m->m.id, Function.identity())); + .collect(Collectors.toMap(m->XTEA.deobfuscateObjectID(m.id, ObfuscatedObjectIDType.MAIL_MESSAGE), Function.identity())); + postprocessMessages(msgs.values()); + return msgs; + } + + private static void postprocessMessages(Collection messages) throws SQLException{ + Set needFileIDs=messages.stream() + .filter(p->p.attachments!=null && !p.attachments.isEmpty()) + .flatMap(p->p.attachments.stream()) + .map(att->att instanceof LocalImage li ? li.fileID : 0L) + .filter(id->id!=0) + .collect(Collectors.toSet()); + if(needFileIDs.isEmpty()) + return; + Map mediaFiles=MediaStorage.getMediaFileRecords(needFileIDs); + for(MailMessage msg:messages){ + if(msg.attachments!=null){ + for(ActivityPubObject attachment:msg.attachments){ + if(attachment instanceof LocalImage li){ + MediaFileRecord mfr=mediaFiles.get(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } + } + } + } } } diff --git a/src/main/java/smithereen/storage/MediaCache.java b/src/main/java/smithereen/storage/MediaCache.java index cbd7c258..87ee8c58 100644 --- a/src/main/java/smithereen/storage/MediaCache.java +++ b/src/main/java/smithereen/storage/MediaCache.java @@ -1,8 +1,5 @@ package smithereen.storage; -import com.google.gson.JsonParser; - -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,37 +8,33 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Flow; -import okhttp3.Call; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; import smithereen.Config; -import smithereen.storage.sql.DatabaseConnection; -import smithereen.storage.sql.DatabaseConnectionManager; -import smithereen.storage.sql.SQLQueryBuilder; -import smithereen.util.DisallowLocalhostInterceptor; import smithereen.LruCache; import smithereen.Utils; -import smithereen.activitypub.ParserContext; -import smithereen.activitypub.objects.ActivityPubObject; -import smithereen.activitypub.objects.Document; -import smithereen.activitypub.objects.LocalImage; +import smithereen.activitypub.ActivityPub; import smithereen.libvips.VipsImage; -import smithereen.util.UserAgentInterceptor; +import smithereen.storage.sql.DatabaseConnection; +import smithereen.storage.sql.DatabaseConnectionManager; +import smithereen.storage.sql.SQLQueryBuilder; public class MediaCache{ private static final Logger LOG=LoggerFactory.getLogger(MediaCache.class); @@ -49,7 +42,6 @@ public class MediaCache{ private LruCache metaCache=new LruCache<>(500); private ExecutorService asyncUpdater; - private OkHttpClient httpClient; private long cacheSize=-1; private final Object cacheSizeLock=new Object(); @@ -61,10 +53,6 @@ public static MediaCache getInstance(){ private MediaCache(){ asyncUpdater=Executors.newFixedThreadPool(1); - httpClient=new OkHttpClient.Builder() - .addNetworkInterceptor(new DisallowLocalhostInterceptor()) - .addNetworkInterceptor(new UserAgentInterceptor()) - .build(); try{ updateTotalSize(); }catch(SQLException x){ @@ -126,29 +114,35 @@ public Item downloadAndPut(URI uri, String mime, ItemType type, boolean lossless byte[] key=keyForURI(uri); String keyHex=Utils.byteArrayToHexString(key); - Request req=new Request.Builder() - .url(uri.toString()) - .build(); - Call call=httpClient.newCall(req); - Response resp=call.execute(); - if(!resp.isSuccessful()){ - resp.body().close(); - return null; - } + HttpRequest req=HttpRequest.newBuilder(uri).build(); Item result=null; - try(ResponseBody body=resp.body()){ - if(body.contentLength()>Config.mediaCacheFileSizeLimit){ - throw new IOException("File too large"); - } - File tmp=File.createTempFile(keyHex, null); - InputStream in=body.byteStream(); - FileOutputStream out=new FileOutputStream(tmp); - int read; - byte[] buf=new byte[10240]; - while((read=in.read(buf))>0){ - out.write(buf, 0, read); + File tmp=File.createTempFile(keyHex, null); + try{ + HttpResponse resp=ActivityPub.httpClient.send(req, responseInfo->{ + if(responseInfo.headers().firstValueAsLong("content-length").orElse(Long.MAX_VALUE)>Config.mediaCacheFileSizeLimit) + return new HttpResponse.BodySubscriber<>(){ + @Override + public CompletionStage getBody(){ + return CompletableFuture.failedStage(new IOException("File too large")); + } + + @Override + public void onSubscribe(Flow.Subscription subscription){} + + @Override + public void onNext(List item){} + + @Override + public void onError(Throwable throwable){} + + @Override + public void onComplete(){} + }; + return HttpResponse.BodySubscribers.ofFile(tmp.toPath()); + }); + if(resp.statusCode()/100!=2){ + return null; } - out.close(); if(!Config.mediaCachePath.exists()){ Config.mediaCachePath.mkdirs(); @@ -168,8 +162,9 @@ public Item downloadAndPut(URI uri, String mime, ItemType type, boolean lossless img.release(); img=flat; } - int[] size={0,0}; - photo.totalSize=MediaStorageUtils.writeResizedWebpImage(img, 2560, 0, lossless ? MediaStorageUtils.QUALITY_LOSSLESS : 93, keyHex, Config.mediaCachePath, size); + int[] size={0, 0}; + File destination=new File(Config.mediaCachePath, keyHex+".webp"); + photo.totalSize=MediaStorageUtils.writeResizedWebpImage(img, 2560, 0, lossless ? MediaStorageUtils.QUALITY_LOSSLESS : 93, destination, size); photo.width=size[0]; photo.height=size[1]; photo.key=keyHex; @@ -181,7 +176,10 @@ public Item downloadAndPut(URI uri, String mime, ItemType type, boolean lossless img.release(); } } - tmp.delete(); + }catch(InterruptedException ignored){ + }finally{ + if(tmp.exists()) + tmp.delete(); } //System.out.println("Total size: "+result.totalSize); metaCache.put(keyHex, result); @@ -216,58 +214,6 @@ private byte[] keyForURI(URI uri){ } } - public static void putDraftAttachment(@NotNull LocalImage img, int ownerID) throws SQLException{ - new SQLQueryBuilder() - .insertInto("draft_attachments") - .value("id", Utils.hexStringToByteArray(img.localID)) - .value("owner_account_id", ownerID) - .value("info", MediaStorageUtils.serializeAttachment(img).toString()) - .executeNoResult(); - } - - public static boolean deleteDraftAttachment(@NotNull String id, int ownerID) throws Exception{ - try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ - String json=new SQLQueryBuilder(conn) - .selectFrom("draft_attachments") - .columns("info") - .where("id=? AND owner_account_id=?", Utils.hexStringToByteArray(id), ownerID) - .executeAndGetSingleObject(r->r.getString(1)); - if(json==null) - return false; - - ActivityPubObject obj=ActivityPubObject.parse(JsonParser.parseString(json).getAsJsonObject(), ParserContext.LOCAL); - if(obj instanceof Document doc) - MediaStorageUtils.deleteAttachmentFiles(doc); - - return new SQLQueryBuilder(conn) - .deleteFrom("draft_attachments") - .where("id=? AND owner_account_id=?", Utils.hexStringToByteArray(id), ownerID) - .executeUpdate()==1; - } - } - - public static ActivityPubObject getAndDeleteDraftAttachment(@NotNull String id, int ownerID, String dir) throws SQLException{ - try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ - String json=new SQLQueryBuilder(conn) - .selectFrom("draft_attachments") - .columns("info") - .where("id=? AND owner_account_id=?", Utils.hexStringToByteArray(id), ownerID) - .executeAndGetSingleObject(r->r.getString(1)); - if(json==null) - return null; - - ActivityPubObject obj=ActivityPubObject.parse(JsonParser.parseString(json).getAsJsonObject(), ParserContext.LOCAL); - if(obj instanceof LocalImage li && !dir.equals(li.path)) - return null; - - new SQLQueryBuilder(conn) - .deleteFrom("draft_attachments") - .where("id=? AND owner_account_id=?", Utils.hexStringToByteArray(id), ownerID) - .executeNoResult(); - return obj; - } - } - public enum ItemType{ PHOTO, AVATAR @@ -359,7 +305,7 @@ public void run(){ break; } } - LOG.info("Deleting from media cache: {}", deletedKeys); + LOG.debug("Deleting from media cache: {}", deletedKeys); if(!deletedKeys.isEmpty()){ conn.createStatement().execute("DELETE FROM `media_cache` WHERE `url_hash` IN ("+String.join(",", deletedKeys)+")"); } diff --git a/src/main/java/smithereen/storage/MediaStorage.java b/src/main/java/smithereen/storage/MediaStorage.java new file mode 100644 index 00000000..cb2b1ec3 --- /dev/null +++ b/src/main/java/smithereen/storage/MediaStorage.java @@ -0,0 +1,122 @@ +package smithereen.storage; + +import java.sql.SQLException; +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import smithereen.LruCache; +import smithereen.Utils; +import smithereen.model.media.MediaFileID; +import smithereen.model.media.MediaFileMetadata; +import smithereen.model.media.MediaFileRecord; +import smithereen.model.media.MediaFileReferenceType; +import smithereen.model.media.MediaFileType; +import smithereen.storage.sql.SQLQueryBuilder; + +public class MediaStorage{ + private static final LruCache recordCache=new LruCache<>(5000); + + public static synchronized Map getMediaFileRecords(Collection ids) throws SQLException{ + if(ids.isEmpty()) + return Map.of(); + HashMap res=new HashMap<>(); + Set needIDs=new HashSet<>(); + for(long id:ids){ + MediaFileRecord r=recordCache.get(id); + if(r==null) + needIDs.add(id); + else + res.put(id, r); + } + if(needIDs.isEmpty()) + return res; + new SQLQueryBuilder() + .selectFrom("media_files") + .columns("id", "random_id", "size", "type", "created_at", "metadata", "original_owner_id") + .whereIn("id", needIDs) + .executeAsStream(MediaFileRecord::fromResultSet) + .forEach(r->{ + res.put(r.id().id(), r); + recordCache.put(r.id().id(), r); + }); + return res; + } + + public static synchronized MediaFileRecord getMediaFileRecord(long id) throws SQLException{ + MediaFileRecord r=recordCache.get(id); + if(r!=null) + return r; + r=new SQLQueryBuilder() + .selectFrom("media_files") + .columns("id", "random_id", "size", "type", "created_at", "metadata", "original_owner_id") + .where("id=?", id) + .executeAndGetSingleObject(MediaFileRecord::fromResultSet); + if(r!=null) + recordCache.put(id, r); + return r; + } + + public static MediaFileRecord createMediaFileRecord(MediaFileType type, long fileSize, int ownerID, MediaFileMetadata metadata) throws SQLException{ + byte[] randomID=Utils.randomBytes(18); + long id=new SQLQueryBuilder() + .insertInto("media_files") + .value("random_id", randomID) + .value("size", fileSize) + .value("type", type) + .value("metadata", Utils.gson.toJson(metadata)) + .value("original_owner_id", ownerID) + .executeAndGetIDLong(); + MediaFileRecord mfr=new MediaFileRecord( + new MediaFileID(id, randomID, ownerID, type), + fileSize, Instant.now(), metadata + ); + synchronized(MediaStorage.class){ + recordCache.put(id, mfr); + } + return mfr; + } + + public static void createMediaFileReference(long fileID, long objectID, MediaFileReferenceType type, int ownerID) throws SQLException{ + new SQLQueryBuilder() + .insertInto("media_file_refs") + .value("file_id", fileID) + .value("object_id", objectID) + .value("object_type", type) + .value(ownerID<0 ? "owner_group_id" : "owner_user_id", ownerID!=0 ? Math.abs(ownerID) : null) + .executeNoResult(); + } + + public static void deleteMediaFileReferences(long objectID, MediaFileReferenceType type) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("media_file_refs") + .where("object_id=? AND object_type=?", objectID, type) + .executeNoResult(); + } + + public static void deleteMediaFileReference(long objectID, MediaFileReferenceType type, long fileID) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("media_file_refs") + .where("object_id=? AND object_type=? AND file_id=?", objectID, type, fileID) + .executeNoResult(); + } + + public static List getUnreferencedMediaFileRecords() throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("media_files") + .where("ref_count=0 AND created_at<(CURRENT_TIMESTAMP()-INTERVAL 1 DAY)") + .executeAsStream(MediaFileRecord::fromResultSet) + .toList(); + } + + public static void deleteMediaFileRecords(Collection ids) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("media_files") + .whereIn("id", ids) + .executeNoResult(); + } +} diff --git a/src/main/java/smithereen/storage/MediaStorageUtils.java b/src/main/java/smithereen/storage/MediaStorageUtils.java index 94b25ed4..e97dfc84 100644 --- a/src/main/java/smithereen/storage/MediaStorageUtils.java +++ b/src/main/java/smithereen/storage/MediaStorageUtils.java @@ -8,23 +8,38 @@ import java.io.File; import java.io.IOException; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import smithereen.Config; +import smithereen.Utils; import smithereen.activitypub.SerializerContext; import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.Document; import smithereen.activitypub.objects.LocalImage; import smithereen.libvips.VipsImage; +import smithereen.model.ObfuscatedObjectIDType; +import smithereen.model.media.MediaFileID; +import smithereen.model.media.MediaFileRecord; +import smithereen.storage.media.MediaFileStorageDriver; +import smithereen.storage.sql.DatabaseConnection; +import smithereen.storage.sql.DatabaseConnectionManager; +import smithereen.storage.sql.SQLQueryBuilder; +import smithereen.util.JsonObjectBuilder; +import smithereen.util.XTEA; import spark.utils.StringUtils; public class MediaStorageUtils{ private static final Logger LOG=LoggerFactory.getLogger(MediaStorageUtils.class); public static final int QUALITY_LOSSLESS=-1; - public static long writeResizedWebpImage(VipsImage img, int widthOrSize, int height, int quality, String keyHex, File basePath, int[] outSize) throws IOException{ - File file=new File(basePath, keyHex+".webp"); + public static long writeResizedWebpImage(VipsImage img, int widthOrSize, int height, int quality, File file, int[] outSize) throws IOException{ double factor; if(height==0){ factor=(double) widthOrSize/(double) Math.max(img.getWidth(), img.getHeight()); @@ -66,46 +81,59 @@ public static long writeResizedWebpImage(VipsImage img, int widthOrSize, int hei return file.length(); } - public static void deleteAttachmentFiles(List attachments){ - for(ActivityPubObject o:attachments){ - if(o instanceof Document) - deleteAttachmentFiles((Document)o); + public static JsonObject serializeAttachment(ActivityPubObject att){ + if(att instanceof LocalImage li){ + return new JsonObjectBuilder() + .add("type", "_LocalImage") + .add("_fileID", li.fileID) + .build(); } + JsonObject o=att.asActivityPubObject(null, new SerializerContext(null, (String)null)); + return o; } - public static void deleteAttachmentFiles(Document doc){ - if(doc instanceof LocalImage img){ - File file=new File(Config.uploadPath, img.path+"/"+img.localID+".webp"); - if(file.exists()) - file.delete(); - else - LOG.warn("{} does not exist", file.getAbsolutePath()); + public static void fillAttachmentObjects(List attachObjects, List attachmentIDs, int attachmentCount, int maxAttachments) throws SQLException{ + for(String id:attachmentIDs){ + String[] idParts=id.split(":"); + if(idParts.length!=2) + continue; + long fileID; + byte[] fileRandomID; + try{ + byte[] _fileID=Base64.getUrlDecoder().decode(idParts[0]); + fileRandomID=Base64.getUrlDecoder().decode(idParts[1]); + if(_fileID.length!=8 || fileRandomID.length!=18) + continue; + fileID=XTEA.deobfuscateObjectID(Utils.unpackLong(_fileID), ObfuscatedObjectIDType.MEDIA_FILE); + }catch(IllegalArgumentException x){ + continue; + } + MediaFileRecord mfr=MediaStorage.getMediaFileRecord(fileID); + if(mfr==null || !Arrays.equals(mfr.id().randomID(), fileRandomID)) + continue; + LocalImage img=new LocalImage(); + img.fileID=fileID; + img.fillIn(mfr); + attachObjects.add(img); + attachmentCount++; + if(attachmentCount==maxAttachments) + break; } } - public static JsonObject serializeAttachment(ActivityPubObject att){ - JsonObject o=att.asActivityPubObject(null, new SerializerContext(null, (String)null)); - if(att instanceof Document){ - Document d=(Document) att; - if(StringUtils.isNotEmpty(d.localID)){ - o.addProperty("_lid", d.localID); - if(d instanceof LocalImage){ - LocalImage im=(LocalImage) d; - JsonArray sizes=new JsonArray(); - sizes.add(im.width); - sizes.add(im.height); - o.add("_sz", sizes); - if(im.path!=null) - o.addProperty("_p", im.path); - o.addProperty("type", "_LocalImage"); - } - o.remove("url"); - o.remove("id"); - o.remove("width"); - o.remove("height"); - o.remove("mediaType"); + public static void deleteAbandonedFiles(){ + try{ + List fileRecords=MediaStorage.getUnreferencedMediaFileRecords(); + if(fileRecords.isEmpty()){ + LOG.trace("No files to delete"); + return; } + LOG.trace("Deleting: {}", fileRecords); + Set deleted=MediaFileStorageDriver.getInstance().deleteFiles(fileRecords.stream().map(MediaFileRecord::id).collect(Collectors.toSet())); + if(!deleted.isEmpty()) + MediaStorage.deleteMediaFileRecords(deleted.stream().map(MediaFileID::id).collect(Collectors.toSet())); + }catch(SQLException x){ + LOG.warn("Failed to delete unused media files", x); } - return o; } } diff --git a/src/main/java/smithereen/storage/ModerationStorage.java b/src/main/java/smithereen/storage/ModerationStorage.java index 3caed2c5..845ba4b8 100644 --- a/src/main/java/smithereen/storage/ModerationStorage.java +++ b/src/main/java/smithereen/storage/ModerationStorage.java @@ -1,32 +1,52 @@ package smithereen.storage; +import com.google.gson.JsonObject; + import org.jetbrains.annotations.Nullable; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; +import java.time.Instant; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import smithereen.Utils; +import smithereen.model.ActorStaffNote; +import smithereen.model.AuditLogEntry; +import smithereen.model.EmailDomainBlockRule; +import smithereen.model.EmailDomainBlockRuleFull; +import smithereen.model.IPBlockRule; +import smithereen.model.IPBlockRuleFull; import smithereen.model.PaginatedList; import smithereen.model.Server; +import smithereen.model.SignupInvitation; +import smithereen.model.UserRole; import smithereen.model.ViolationReport; +import smithereen.model.ViolationReportAction; +import smithereen.model.viewmodel.AdminUserViewModel; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; import smithereen.storage.sql.SQLQueryBuilder; +import smithereen.storage.utils.IntPair; +import smithereen.util.InetAddressRange; import spark.utils.StringUtils; public class ModerationStorage{ - public static int createViolationReport(int reporterID, ViolationReport.TargetType targetType, int targetID, ViolationReport.ContentType contentType, long contentID, String comment, String otherServerDomain) throws SQLException{ + public static int createViolationReport(int reporterID, int targetID, String comment, String otherServerDomain, String contentJson) throws SQLException{ SQLQueryBuilder bldr=new SQLQueryBuilder() .insertInto("reports") .value("reporter_id", reporterID!=0 ? reporterID : null) - .value("target_type", targetType.ordinal()) .value("target_id", targetID) .value("comment", comment) - .value("server_domain", otherServerDomain); - if(contentType!=null){ - bldr.value("content_type", contentType.ordinal()) - .value("content_id", contentID); - } + .value("server_domain", otherServerDomain) + .value("content", contentJson); return bldr.executeAndGetID(); } @@ -34,7 +54,7 @@ public static int getViolationReportsCount(boolean open) throws SQLException{ return new SQLQueryBuilder() .selectFrom("reports") .count() - .where("action_time IS"+(open ? "" : " NOT")+" NULL") + .where("state"+(open ? "=" : "<>")+" ?", ViolationReport.State.OPEN) .executeAndGetInt(); } @@ -45,7 +65,7 @@ public static PaginatedList getViolationReports(boolean open, i List reports=new SQLQueryBuilder() .selectFrom("reports") .allColumns() - .where("action_time IS"+(open ? "" : " NOT")+" NULL") + .where("state"+(open ? "=" : "<>")+" ?", ViolationReport.State.OPEN) .limit(count, offset) .orderBy("id DESC") .executeAsStream(ViolationReport::fromResultSet) @@ -53,6 +73,48 @@ public static PaginatedList getViolationReports(boolean open, i return new PaginatedList<>(reports, total, offset, count); } + public static PaginatedList getViolationReportsOfActor(int actorID, int offset, int count) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int total=new SQLQueryBuilder(conn) + .selectFrom("reports") + .count() + .where("target_id=?", actorID) + .executeAndGetInt(); + if(total==0) + return PaginatedList.emptyList(count); + List reports=new SQLQueryBuilder(conn) + .selectFrom("reports") + .allColumns() + .where("target_id=?", actorID) + .limit(count, offset) + .orderBy("id DESC") + .executeAsStream(ViolationReport::fromResultSet) + .toList(); + return new PaginatedList<>(reports, total, offset, count); + } + } + + public static PaginatedList getViolationReportsByUser(int userID, int offset, int count) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int total=new SQLQueryBuilder(conn) + .selectFrom("reports") + .count() + .where("reporter_id=?", userID) + .executeAndGetInt(); + if(total==0) + return PaginatedList.emptyList(count); + List reports=new SQLQueryBuilder(conn) + .selectFrom("reports") + .allColumns() + .where("reporter_id=?", userID) + .limit(count, offset) + .orderBy("id DESC") + .executeAsStream(ViolationReport::fromResultSet) + .toList(); + return new PaginatedList<>(reports, total, offset, count); + } + } + public static ViolationReport getViolationReportByID(int id) throws SQLException{ return new SQLQueryBuilder() .selectFrom("reports") @@ -137,12 +199,356 @@ public static void setServerAvailability(int id, LocalDate lastErrorDay, int err .executeNoResult(); } - public static void setViolationReportResolved(int reportID, int moderatorID) throws SQLException{ + public static Map getRoleAccountCounts() throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("accounts") + .selectExpr("role, count(*)") + .where("role IS NOT NULL") + .groupBy("role") + .executeAsStream(rs->new IntPair(rs.getInt(1), rs.getInt(2))) + .collect(Collectors.toMap(IntPair::first, IntPair::second)); + } + + public static void updateRole(int id, String name, EnumSet permissions) throws SQLException{ + new SQLQueryBuilder() + .update("user_roles") + .value("name", name) + .value("permissions", Utils.serializeEnumSetToBytes(permissions)) + .where("id=?", id) + .executeNoResult(); + } + + public static int createRole(String name, EnumSet permissions) throws SQLException{ + return new SQLQueryBuilder() + .insertInto("user_roles") + .value("name", name) + .value("permissions", Utils.serializeEnumSetToBytes(permissions)) + .executeAndGetID(); + } + + public static void deleteRole(int id) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("user_roles") + .where("id=?", id) + .executeNoResult(); + } + + public static void createAuditLogEntry(int adminID, AuditLogEntry.Action action, int ownerID, long objectID, AuditLogEntry.ObjectType objectType, Map extra) throws SQLException{ + new SQLQueryBuilder() + .insertInto("audit_log") + .value("admin_id", adminID) + .value("action", action) + .value("owner_id", ownerID) + .value("object_id", objectID) + .value("object_type", objectType) + .value("extra", extra==null ? null : Utils.gson.toJson(extra)) + .executeNoResult(); + } + + public static PaginatedList getGlobalAuditLog(int offset, int count) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int total=new SQLQueryBuilder(conn) + .selectFrom("audit_log") + .count() + .executeAndGetInt(); + if(total==0) + return PaginatedList.emptyList(count); + return new PaginatedList<>(new SQLQueryBuilder(conn) + .selectFrom("audit_log") + .allColumns() + .orderBy("id DESC") + .limit(count, offset) + .executeAsStream(AuditLogEntry::fromResultSet) + .toList(), total, offset, count); + } + } + + public static PaginatedList getUserAuditLog(int userID, int offset, int count) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int total=new SQLQueryBuilder(conn) + .selectFrom("audit_log") + .count() + .where("owner_id=?", userID) + .executeAndGetInt(); + if(total==0) + return PaginatedList.emptyList(count); + return new PaginatedList<>(new SQLQueryBuilder(conn) + .selectFrom("audit_log") + .allColumns() + .where("owner_id=?", userID) + .orderBy("id DESC") + .limit(count, offset) + .executeAsStream(AuditLogEntry::fromResultSet) + .toList(), total, offset, count); + } + } + + public static PaginatedList getUsers(String q, Boolean localOnly, String emailDomain, InetAddressRange ipRange, int role, int offset, int count) throws SQLException{ + if(StringUtils.isNotEmpty(q)){ + q=Arrays.stream(Utils.transliterate(q).replaceAll("[()\\[\\]*+~<>\\\"@-]", " ").split("[ \t]+")).filter(Predicate.not(String::isBlank)).map(s->'+'+s+'*').collect(Collectors.joining(" ")); + } + ArrayList whereParts=new ArrayList<>(); + ArrayList whereArgs=new ArrayList<>(); + String selection="`users`.id AS user_id, accounts.id AS account_id, accounts.role, accounts.last_active, accounts.email, accounts.activation_info, accounts.last_ip"; + String query=" FROM `users` LEFT JOIN accounts ON users.id=accounts.user_id"; + if(StringUtils.isNotEmpty(q)){ + query+=" JOIN qsearch_index ON `users`.id=qsearch_index.user_id"; + whereParts.add("MATCH (qsearch_index.`string`) AGAINST (? IN BOOLEAN MODE)"); + whereArgs.add(q); + } + if(localOnly!=null){ + if(localOnly) + whereParts.add("`users`.ap_id IS NULL"); + else + whereParts.add("`users`.ap_id IS NOT NULL"); + } + if(StringUtils.isNotEmpty(emailDomain)){ + whereParts.add("accounts.email_domain=?"); + whereArgs.add(emailDomain); + } + if(ipRange!=null){ + if(ipRange.isSingleAddress()){ + whereParts.add("accounts.last_ip=?"); + whereArgs.add(Utils.serializeInetAddress(ipRange.address())); + }else{ + whereParts.add("accounts.last_ip>=? AND accounts.last_ip0){ + whereParts.add("accounts.role=?"); + whereArgs.add(role); + } + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + String where; + if(whereParts.isEmpty()) + where=""; + else + where=" WHERE ("+String.join(") AND (", whereParts)+")"; + PreparedStatement stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT COUNT(*)"+query+where, whereArgs.toArray(new Object[0])); + int total; + try(ResultSet res=stmt.executeQuery()){ + total=DatabaseUtils.oneFieldToInt(res); + } + if(total==0) + return PaginatedList.emptyList(count); + whereArgs.add(count); + whereArgs.add(offset); + stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT "+selection+query+where+" ORDER BY `users`.id ASC LIMIT ? OFFSET ?", whereArgs.toArray(new Object[0])); + try(ResultSet res=stmt.executeQuery()){ + List list=DatabaseUtils.resultSetToObjectStream(res, AdminUserViewModel::fromResultSet, null) + .toList(); + return new PaginatedList<>(list, total, offset, count); + } + } + } + + public static List getViolationReportActions(int id) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("report_actions") + .allColumns() + .where("report_id=?", id) + .orderBy("id ASC") + .executeAsStream(ViolationReportAction::fromResultSet) + .toList(); + } + + public static void createViolationReportAction(int reportID, int userID, ViolationReportAction.ActionType type, String text, JsonObject extra) throws SQLException{ + new SQLQueryBuilder() + .insertInto("report_actions") + .value("report_id", reportID) + .value("user_id", userID) + .value("action_type", type) + .value("text", text) + .value("extra", extra==null ? null : extra.toString()) + .executeNoResult(); + } + + public static void setViolationReportState(int reportID, ViolationReport.State state) throws SQLException{ new SQLQueryBuilder() .update("reports") - .value("moderator_id", moderatorID) - .valueExpr("action_time", "CURRENT_TIMESTAMP()") + .value("state", state) .where("id=?", reportID) .executeNoResult(); } + + public static int getUserStaffNoteCount(int userID) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("user_staff_notes") + .count() + .where("target_id=?", userID) + .executeAndGetInt(); + } + + public static PaginatedList getUserStaffNotes(int userID, int offset, int count) throws SQLException{ + int total=getUserStaffNoteCount(userID); + if(total==0) + return PaginatedList.emptyList(count); + List notes=new SQLQueryBuilder() + .selectFrom("user_staff_notes") + .allColumns() + .where("target_id=?", userID) + .limit(count, offset) + .executeAsStream(ActorStaffNote::fromResultSet) + .toList(); + return new PaginatedList<>(notes, total, offset, count); + } + + public static int createUserStaffNote(int userID, int authorID, String text) throws SQLException{ + return new SQLQueryBuilder() + .insertInto("user_staff_notes") + .value("target_id", userID) + .value("author_id", authorID) + .value("text", text) + .executeAndGetID(); + } + + public static void deleteUserStaffNote(int id) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("user_staff_notes") + .where("id=?", id) + .executeNoResult(); + } + + public static ActorStaffNote getUserStaffNote(int id) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("user_staff_notes") + .where("id=?", id) + .executeAndGetSingleObject(ActorStaffNote::fromResultSet); + } + + public static void createEmailDomainBlockRule(String domain, EmailDomainBlockRule.Action action, String note, int creatorID) throws SQLException{ + new SQLQueryBuilder() + .insertInto("blocks_email_domain") + .value("domain", domain) + .value("action", action) + .value("note", note) + .value("creator_id", creatorID) + .executeNoResult(); + } + + public static void updateEmailDomainBlockRule(String domain, EmailDomainBlockRule.Action action, String note) throws SQLException{ + new SQLQueryBuilder() + .update("blocks_email_domain") + .value("action", action) + .value("note", note) + .where("domain=?", domain) + .executeNoResult(); + } + + public static List getEmailDomainBlockRules() throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("blocks_email_domain") + .columns("domain", "action") + .executeAsStream(EmailDomainBlockRule::fromResultSet) + .toList(); + } + + public static void deleteEmailDomainBlockRule(String domain) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("blocks_email_domain") + .where("domain=?", domain) + .executeNoResult(); + } + + public static List getEmailDomainBlockRulesFull() throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("blocks_email_domain") + .allColumns() + .orderBy("created_at DESC") + .executeAsStream(EmailDomainBlockRuleFull::fromResultSet) + .toList(); + } + + public static EmailDomainBlockRuleFull getEmailDomainBlockRuleFull(String domain) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("blocks_email_domain") + .allColumns() + .where("domain=?", domain) + .executeAndGetSingleObject(EmailDomainBlockRuleFull::fromResultSet); + } + + + public static void createIPBlockRule(InetAddressRange addressRange, IPBlockRule.Action action, Instant expiresAt, String note, int creatorID) throws SQLException{ + new SQLQueryBuilder() + .insertInto("blocks_ip") + .value("address", Utils.serializeInetAddress(addressRange.address())) + .value("prefix_length", addressRange.prefixLength()) + .value("action", action) + .value("note", note) + .value("creator_id", creatorID) + .value("expires_at", expiresAt) + .executeNoResult(); + } + + public static void updateIPBlockRule(int id, IPBlockRule.Action action, Instant expiresAt, String note) throws SQLException{ + new SQLQueryBuilder() + .update("blocks_ip") + .value("action", action) + .value("note", note) + .value("expires_at", expiresAt) + .where("id=?", id) + .executeNoResult(); + } + + public static List getIPBlockRules() throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("blocks_ip") + .columns("id", "address", "prefix_length", "action", "expires_at") + .executeAsStream(IPBlockRule::fromResultSet) + .toList(); + } + + public static void deleteIPBlockRule(int id) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("blocks_ip") + .where("id=?", id) + .executeNoResult(); + } + + public static List getIPBlockRulesFull() throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("blocks_ip") + .allColumns() + .orderBy("created_at DESC") + .executeAsStream(IPBlockRuleFull::fromResultSet) + .toList(); + } + + public static IPBlockRuleFull getIPBlockRuleFull(int id) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("blocks_ip") + .allColumns() + .where("id=?", id) + .executeAndGetSingleObject(IPBlockRuleFull::fromResultSet); + } + + public static PaginatedList getAllSignupInvites(int offset, int count) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int total=new SQLQueryBuilder(conn) + .selectFrom("signup_invitations") + .count() + .where("signups_remaining>0") + .executeAndGetInt(); + if(total==0) + return PaginatedList.emptyList(count); + List invites=new SQLQueryBuilder(conn) + .selectFrom("signup_invitations") + .allColumns() + .where("signups_remaining>0") + .orderBy("id DESC") + .limit(count, offset) + .executeAsStream(SignupInvitation::fromResultSet) + .toList(); + return new PaginatedList<>(invites, total, offset, count); + } + } + + public static void deleteSignupInvite(int id) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("signup_invitations") + .where("id=?", id) + .executeNoResult(); + } } diff --git a/src/main/java/smithereen/storage/PostStorage.java b/src/main/java/smithereen/storage/PostStorage.java index 1bf28f23..7d8fa83e 100644 --- a/src/main/java/smithereen/storage/PostStorage.java +++ b/src/main/java/smithereen/storage/PostStorage.java @@ -27,7 +27,9 @@ import smithereen.Config; import smithereen.Utils; +import smithereen.activitypub.objects.ActivityPubObject; import smithereen.activitypub.objects.Actor; +import smithereen.activitypub.objects.LocalImage; import smithereen.activitypub.objects.activities.Like; import smithereen.model.FederationState; import smithereen.model.ForeignGroup; @@ -37,11 +39,12 @@ import smithereen.model.Poll; import smithereen.model.PollOption; import smithereen.model.Post; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; import smithereen.model.User; import smithereen.model.UserInteractions; import smithereen.model.feed.NewsfeedEntry; import smithereen.exceptions.ObjectNotFoundException; +import smithereen.model.media.MediaFileRecord; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; import smithereen.storage.sql.SQLQueryBuilder; @@ -325,6 +328,7 @@ public static List getWallPosts(int ownerID, boolean isGroup, int minID, i posts.add(Post.fromResultSet(res)); } } + postprocessPosts(posts); return posts; } } @@ -381,6 +385,7 @@ public static List getWallToWall(int userID, int otherUserID, int offset, posts.add(Post.fromResultSet(res)); } } + postprocessPosts(posts); return posts; } } @@ -402,17 +407,20 @@ public static Post getPostByID(int postID, boolean wantDeleted) throws SQLExcept .executeAndGetSingleObject(Post::fromResultSet); if(post==null || (post.isDeleted() && !wantDeleted)) return null; + postprocessPosts(Set.of(post)); return post; } public static Map getPostsByID(Collection ids) throws SQLException{ - return new SQLQueryBuilder() + Map posts=new SQLQueryBuilder() .selectFrom("wall_posts") .allColumns() .whereIn("id", ids) .executeAsStream(Post::fromResultSet) .filter(p->!p.isDeleted()) .collect(Collectors.toMap(p->p.id, Function.identity())); + postprocessPosts(posts.values()); + return posts; } public static Post getPostByID(URI apID) throws SQLException{ @@ -425,11 +433,14 @@ public static Post getPostByID(URI apID) throws SQLException{ } return getPostByID(postID, false); } - return new SQLQueryBuilder() + Post post=new SQLQueryBuilder() .selectFrom("wall_posts") .allColumns() .where("ap_id=?", apID) .executeAndGetSingleObject(Post::fromResultSet); + if(post!=null) + postprocessPosts(Set.of(post)); + return post; } public static int getLocalIDByActivityPubID(URI apID) throws SQLException{ @@ -448,7 +459,7 @@ public static void deletePost(int id) throws SQLException{ PreparedStatement stmt; boolean needFullyDelete=true; if(post.getReplyLevel()>0){ - stmt=conn.prepareStatement("SELECT COUNT(*) FROM wall_posts WHERE reply_key LIKE BINARY bin_prefix(?) ESCAPE CHAR(255)"); + stmt=conn.prepareStatement("SELECT COUNT(*) FROM wall_posts WHERE reply_key LIKE BINARY bin_prefix(?)"); ArrayList rk=new ArrayList<>(post.replyKey.size()+1); rk.add(post.id); stmt.setBytes(1, Utils.serializeIntList(rk)); @@ -504,6 +515,7 @@ public static Map> getRepliesForFeed(Set p posts.add(0, post); } } + postprocessPosts(map.values().stream().flatMap(l->l.list.stream()).toList()); stmt=new SQLQueryBuilder(conn) .selectFrom("wall_posts") .selectExpr("count(*), reply_key") @@ -542,26 +554,27 @@ public static ThreadedReplies getRepliesThreaded(int[] prefix, int topLevelOffse .orderBy("created_at ASC") .executeAsStream(Post::fromResultSet) .collect(Collectors.toList()); - - int repliesOffset; - if(topLevelOffset>0){ - try(PreparedStatement stmt=SQLQueryBuilder.prepareStatement(conn, - "SELECT SUM(reply_count) FROM (SELECT reply_count FROM wall_posts WHERE reply_key=? ORDER BY created_at ASC LIMIT ?) AS subq", - serializedPrefix, topLevelOffset)){ - repliesOffset=DatabaseUtils.oneFieldToInt(stmt.executeQuery()); + postprocessPosts(posts); + + ArrayList wheres=new ArrayList<>(); + ArrayList whereArgs=new ArrayList<>(); + for(Post post:posts){ + if(post.replyCount>0){ + wheres.add("reply_key LIKE BINARY bin_prefix(?)"); + whereArgs.add(Utils.serializeIntList(post.getReplyKeyForReplies())); } - }else{ - repliesOffset=0; } - List replies=new SQLQueryBuilder(conn) - .selectFrom("wall_posts") - .allColumns() - .where("reply_key<>? AND reply_key LIKE BINARY bin_prefix(?) ESCAPE CHAR(255)", serializedPrefix, serializedPrefix) - .orderBy("LENGTH(reply_key) ASC, created_at ASC") - .limit(secondaryLimit, repliesOffset) - .executeAsStream(Post::fromResultSet) - .toList(); + List replies; + if(!whereArgs.isEmpty()){ + whereArgs.add(secondaryLimit); + PreparedStatement stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT * FROM wall_posts WHERE "+String.join(" OR ", wheres)+" ORDER BY created_at ASC, LENGTH(reply_key) ASC LIMIT ?", + whereArgs.toArray()); + replies=DatabaseUtils.resultSetToObjectStream(stmt.executeQuery(), Post::fromResultSet, null).toList(); + postprocessPosts(replies); + }else{ + replies=List.of(); + } return new ThreadedReplies(posts, replies, total); } @@ -585,6 +598,7 @@ public static PaginatedList getRepliesExact(int[] replyKey, int maxID, int .orderBy("created_at ASC") .executeAsStream(Post::fromResultSet) .toList(); + postprocessPosts(posts); return new PaginatedList<>(posts, total, 0, limit); } } @@ -682,7 +696,7 @@ public static Set getInboxesForPostInteractionForwarding(Post post) throws try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ ArrayList queryParts=new ArrayList<>(); if(post.isLocal()){ - queryParts.add("SELECT owner_user_id FROM wall_posts WHERE reply_key LIKE BINARY bin_prefix(?) ESCAPE CHAR(255)"); + queryParts.add("SELECT owner_user_id FROM wall_posts WHERE reply_key LIKE BINARY bin_prefix(?)"); if(owner instanceof ForeignUser fu) queryParts.add("SELECT "+fu.id); else if(owner instanceof User u) @@ -1033,6 +1047,45 @@ public static String getPostSource(int id) throws SQLException{ .executeAndGetSingleObject(r->r.getString(1)); } + public static int getUserPostCount(int id) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("wall_posts") + .count() + .where("owner_user_id=? AND author_id=owner_user_id AND reply_key IS NULL", id) + .executeAndGetInt(); + } + + public static int getUserPostCommentCount(int id) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("wall_posts") + .count() + .where("author_id=? AND reply_key IS NOT NULL", id) + .executeAndGetInt(); + } + + private static void postprocessPosts(Collection posts) throws SQLException{ + Set needFileIDs=posts.stream() + .filter(p->p.attachments!=null && !p.attachments.isEmpty()) + .flatMap(p->p.attachments.stream()) + .map(att->att instanceof LocalImage li ? li.fileID : 0L) + .filter(id->id!=0) + .collect(Collectors.toSet()); + if(needFileIDs.isEmpty()) + return; + Map mediaFiles=MediaStorage.getMediaFileRecords(needFileIDs); + for(Post post:posts){ + if(post.attachments!=null){ + for(ActivityPubObject attachment:post.attachments){ + if(attachment instanceof LocalImage li){ + MediaFileRecord mfr=mediaFiles.get(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } + } + } + } + } + private record DeleteCommentBookmarksRunnable(int postID) implements Runnable{ @Override public void run(){ diff --git a/src/main/java/smithereen/storage/SessionStorage.java b/src/main/java/smithereen/storage/SessionStorage.java index 3ec2a211..531810b9 100644 --- a/src/main/java/smithereen/storage/SessionStorage.java +++ b/src/main/java/smithereen/storage/SessionStorage.java @@ -4,6 +4,8 @@ import org.jetbrains.annotations.NotNull; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; @@ -18,23 +20,28 @@ import java.sql.Types; import java.time.Instant; import java.util.Base64; +import java.util.List; import java.util.Locale; import java.util.Objects; import smithereen.Config; import smithereen.LruCache; import smithereen.Utils; +import smithereen.exceptions.InternalServerErrorException; import smithereen.model.Account; import smithereen.model.AdminNotifications; import smithereen.model.EmailCode; import smithereen.model.Group; +import smithereen.model.OtherSession; import smithereen.model.PaginatedList; import smithereen.model.SessionInfo; import smithereen.model.SignupInvitation; import smithereen.model.SignupRequest; import smithereen.model.User; +import smithereen.model.UserBanStatus; import smithereen.model.UserPermissions; import smithereen.model.UserPreferences; +import smithereen.model.UserRole; import smithereen.model.notifications.Notification; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; @@ -48,17 +55,25 @@ public class SessionStorage{ private static final LruCache permissionsCache=new LruCache<>(500); - public static String putNewSession(@NotNull Session sess) throws SQLException{ + public static String putNewSession(@NotNull Session sess, String userAgent, InetAddress ip) throws SQLException{ byte[] sid=new byte[64]; SessionInfo info=sess.attribute("info"); Account account=info.account; if(account==null) throw new IllegalArgumentException("putNewSession requires a logged in session"); random.nextBytes(sid); + long uaHash=Utils.hashUserAgent(userAgent); + new SQLQueryBuilder() + .insertIgnoreInto("user_agents") + .value("hash", uaHash) + .value("user_agent", userAgent) + .executeNoResult(); new SQLQueryBuilder() .insertInto("sessions") .value("id", sid) .value("account_id", account.id) + .value("user_agent", uaHash) + .value("ip", Utils.serializeInetAddress(ip)) .executeNoResult(); return Base64.getEncoder().encodeToString(sid); } @@ -74,30 +89,47 @@ public static boolean fillSession(String psid, Session sess, Request req) throws return false; try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ - PreparedStatement stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT * FROM `accounts` WHERE `id` IN (SELECT `account_id` FROM `sessions` WHERE `id`=?)", (Object) sid); - try(ResultSet res=stmt.executeQuery()){ - if(!res.next()) - return false; - SessionInfo info=new SessionInfo(); - info.account=Account.fromResultSet(res); - info.csrfToken=Utils.csrfTokenFromSessionID(sid); - if(info.account.prefs.locale==null){ - Locale requestLocale=req.raw().getLocale(); - if(requestLocale!=null){ - info.account.prefs.locale=requestLocale; - SessionStorage.updatePreferences(info.account.id, info.account.prefs); - } + record SessionRow(int accountID, InetAddress ip, long uaHash){} + SessionRow sr=new SQLQueryBuilder(conn) + .selectFrom("sessions") + .allColumns() + .where("id=?", (Object)sid) + .executeAndGetSingleObject(r->{ + try{ + return new SessionRow(r.getInt("account_id"), InetAddress.getByAddress(r.getBytes("ip")), r.getLong("user_agent")); + }catch(UnknownHostException e){ + throw new RuntimeException(e); + } + }); + if(sr==null) + return false; + Account acc=new SQLQueryBuilder(conn) + .selectFrom("accounts") + .allColumns() + .where("id=?", sr.accountID) + .executeAndGetSingleObject(Account::fromResultSet); + if(acc==null) + return false; + SessionInfo info=new SessionInfo(); + info.account=acc; + info.csrfToken=Utils.csrfTokenFromSessionID(sid); + info.ip=sr.ip; + info.userAgentHash=sr.uaHash; + if(info.account.prefs.locale==null){ + Locale requestLocale=req.raw().getLocale(); + if(requestLocale!=null){ + info.account.prefs.locale=requestLocale; + SessionStorage.updatePreferences(info.account.id, info.account.prefs); } - sess.attribute("info", info); } + sess.attribute("info", info); } return true; } public static Account getAccountForUsernameAndPassword(@NotNull String usernameOrEmail, @NotNull String password) throws SQLException{ try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ - MessageDigest md=MessageDigest.getInstance("SHA-256"); - byte[] hashedPassword=md.digest(password.getBytes(StandardCharsets.UTF_8)); + byte[] hashedPassword=hashPassword(password); PreparedStatement stmt; if(usernameOrEmail.contains("@")){ stmt=conn.prepareStatement("SELECT * FROM `accounts` WHERE `email`=? AND `password`=?"); @@ -112,8 +144,7 @@ public static Account getAccountForUsernameAndPassword(@NotNull String usernameO } return null; } - }catch(NoSuchAlgorithmException ignore){} - throw new AssertionError(); + } } public static void deleteSession(@NotNull String psid) throws SQLException{ @@ -127,6 +158,25 @@ public static void deleteSession(@NotNull String psid) throws SQLException{ .executeNoResult(); } + public static void deleteSession(int accountID, @NotNull byte[] sid) throws SQLException{ + if(sid.length!=64) + return; + + new SQLQueryBuilder() + .deleteFrom("sessions") + .where("id=? AND account_id=?", (Object) sid, accountID) + .executeNoResult(); + } + + private static byte[] hashPassword(String password){ + try{ + MessageDigest md=MessageDigest.getInstance("SHA-256"); + return md.digest(password.getBytes(StandardCharsets.UTF_8)); + }catch(NoSuchAlgorithmException x){ + throw new InternalServerErrorException(x); + } + } + public static SignupResult registerNewAccount(@NotNull String username, @NotNull String password, @NotNull String email, @NotNull String firstName, @NotNull String lastName, @NotNull User.Gender gender, @NotNull String invite) throws SQLException{ SignupResult[] result={SignupResult.SUCCESS}; try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ @@ -147,10 +197,8 @@ public static SignupResult registerNewAccount(@NotNull String username, @NotNull int inviterAccountID=inv.ownerID; KeyPairGenerator kpg; - MessageDigest md; try{ kpg=KeyPairGenerator.getInstance("RSA"); - md=MessageDigest.getInstance("SHA-256"); }catch(NoSuchAlgorithmException x){ throw new RuntimeException(x); } @@ -171,7 +219,7 @@ public static SignupResult registerNewAccount(@NotNull String username, @NotNull userID=res.getInt(1); } - byte[] hashedPassword=md.digest(password.getBytes(StandardCharsets.UTF_8)); + byte[] hashedPassword=hashPassword(password); stmt=conn.prepareStatement("INSERT INTO `accounts` (`user_id`, `email`, `password`, `invited_by`) VALUES (?, ?, ?, ?)"); stmt.setInt(1, userID); stmt.setString(2, email); @@ -229,10 +277,8 @@ public static SignupResult registerNewAccount(@NotNull String username, @NotNull .executeNoResult(); KeyPairGenerator kpg; - MessageDigest md; try{ kpg=KeyPairGenerator.getInstance("RSA"); - md=MessageDigest.getInstance("SHA-256"); }catch(NoSuchAlgorithmException x){ throw new RuntimeException(x); } @@ -253,7 +299,7 @@ public static SignupResult registerNewAccount(@NotNull String username, @NotNull userID=res.getInt(1); } - byte[] hashedPassword=md.digest(password.getBytes(StandardCharsets.UTF_8)); + byte[] hashedPassword=hashPassword(password); stmt=conn.prepareStatement("INSERT INTO `accounts` (`user_id`, `email`, `password`, `invited_by`) VALUES (?, ?, ?, ?)"); stmt.setInt(1, userID); stmt.setString(2, email); @@ -281,17 +327,13 @@ public static void updateActivationInfo(int accountID, Account.ActivationInfo in } public static boolean updatePassword(int accountID, String oldPassword, String newPassword) throws SQLException{ - try{ - MessageDigest md=MessageDigest.getInstance("SHA-256"); - byte[] hashedOld=md.digest(oldPassword.getBytes(StandardCharsets.UTF_8)); - byte[] hashedNew=md.digest(newPassword.getBytes(StandardCharsets.UTF_8)); - return new SQLQueryBuilder() - .update("account") - .value("password", hashedNew) - .where("id=? AND `password`=?", accountID, hashedOld) - .executeUpdate()==1; - }catch(NoSuchAlgorithmException ignore){} - return false; + byte[] hashedOld=hashPassword(oldPassword); + byte[] hashedNew=hashPassword(newPassword); + return new SQLQueryBuilder() + .update("accounts") + .value("password", hashedNew) + .where("id=? AND `password`=?", accountID, hashedOld) + .executeUpdate()==1; } public static void updateEmail(int accountID, String email) throws SQLException{ @@ -335,6 +377,14 @@ public static Account getAccountByUsername(String username) throws SQLException{ } } + public static Account getAccountByUserID(int userID) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("accounts") + .allColumns() + .where("user_id=?", userID) + .executeAndGetSingleObject(Account::fromResultSet); + } + public static String storeEmailCode(EmailCode code) throws SQLException{ byte[] _id=new byte[64]; random.nextBytes(_id); @@ -397,38 +447,68 @@ public static void deleteExpiredEmailCodes() throws SQLException{ } public static boolean updatePassword(int accountID, String newPassword) throws SQLException{ - try{ - MessageDigest md=MessageDigest.getInstance("SHA-256"); - byte[] hashedNew=md.digest(newPassword.getBytes(StandardCharsets.UTF_8)); - return new SQLQueryBuilder() - .update("accounts") - .value("password", hashedNew) - .where("id=?", accountID) - .executeUpdate()==1; - }catch(NoSuchAlgorithmException ignore){} - return false; + byte[] hashedNew=hashPassword(newPassword); + return new SQLQueryBuilder() + .update("accounts") + .value("password", hashedNew) + .where("id=?", accountID) + .executeUpdate()==1; + } + + public static boolean checkPassword(int accountID, String password) throws SQLException{ + byte[] hashed=hashPassword(password); + return new SQLQueryBuilder() + .selectFrom("accounts") + .count() + .where("id=? AND password=?", accountID, hashed) + .executeAndGetInt()==1; } - public static void setLastActive(int accountID, String psid, Instant time) throws SQLException{ + public static void setLastActive(int accountID, String psid, Instant time, InetAddress lastIP, String userAgent, long uaHash) throws SQLException{ try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + byte[] serializedIP=Utils.serializeInetAddress(lastIP); new SQLQueryBuilder(conn) .update("accounts") .value("last_active", time) + .value("last_ip", serializedIP) .where("id=?", accountID) .executeNoResult(); byte[] sid=Base64.getDecoder().decode(psid); new SQLQueryBuilder(conn) .update("sessions") .value("last_active", time) + .value("ip", serializedIP) + .value("user_agent", uaHash) .where("id=?", (Object) sid) .executeNoResult(); + new SQLQueryBuilder(conn) + .insertIgnoreInto("user_agents") + .value("hash", uaHash) + .value("user_agent", userAgent) + .executeNoResult(); } } +// +// public static void setIpAndUserAgent(String psid, InetAddress ip, String userAgent, long uaHash) throws SQLException{ +// byte[] sid=Base64.getDecoder().decode(psid); +// try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ +// new SQLQueryBuilder(conn) +// .update("sessions") +// .where("id=?", (Object) sid) +// .value("ip", Utils.serializeInetAddress(ip)) +// .value("user_agent", uaHash) +// .executeNoResult(); +// } +// } public static synchronized void removeFromUserPermissionsCache(int userID){ permissionsCache.remove(userID); } + public static synchronized void resetPermissionsCache(){ + permissionsCache.evictAll(); + } + public static synchronized UserPermissions getUserPermissions(Account account) throws SQLException{ UserPermissions r=permissionsCache.get(account.user.id); if(r!=null) @@ -448,8 +528,8 @@ public static synchronized UserPermissions getUserPermissions(Account account) t stmt.close(); } r.canInviteNewUsers=switch(Config.signupMode){ - case OPEN, INVITE_ONLY -> true; - case CLOSED, MANUAL_APPROVAL -> r.serverAccessLevel==Account.AccessLevel.ADMIN || r.serverAccessLevel==Account.AccessLevel.MODERATOR; + case OPEN, INVITE_ONLY -> account.user.banStatus==UserBanStatus.NONE || account.user.banStatus==UserBanStatus.HIDDEN; + case CLOSED, MANUAL_APPROVAL -> r.hasPermission(UserRole.Permission.MANAGE_INVITES); }; permissionsCache.put(account.user.id, r); return r; @@ -573,6 +653,22 @@ public static SignupRequest getInviteRequestByEmail(String email) throws SQLExce .executeAndGetSingleObject(SignupRequest::fromResultSet); } + public static List getAccountSessions(int accountID) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + PreparedStatement stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT sessions.*, user_agents.user_agent AS user_agent_str FROM sessions LEFT JOIN user_agents ON sessions.user_agent=user_agents.hash WHERE account_id=? ORDER BY last_active DESC", accountID); + try(ResultSet res=stmt.executeQuery()){ + return DatabaseUtils.resultSetToObjectStream(res, OtherSession::fromResultSet, null).toList(); + } + } + } + + public static void deleteSessionsExcept(int accountID, byte[] sid) throws SQLException{ + new SQLQueryBuilder() + .deleteFrom("sessions") + .where("account_id=? AND id<>?", accountID, sid) + .executeNoResult(); + } + public enum SignupResult{ SUCCESS, USERNAME_TAKEN, diff --git a/src/main/java/smithereen/storage/UserStorage.java b/src/main/java/smithereen/storage/UserStorage.java index 1b3c1fd3..5ef077fa 100644 --- a/src/main/java/smithereen/storage/UserStorage.java +++ b/src/main/java/smithereen/storage/UserStorage.java @@ -10,7 +10,9 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; @@ -22,6 +24,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -29,6 +32,7 @@ import smithereen.LruCache; import smithereen.Utils; import smithereen.activitypub.SerializerContext; +import smithereen.activitypub.objects.LocalImage; import smithereen.model.Account; import smithereen.model.BirthdayReminder; import smithereen.model.EventReminder; @@ -39,8 +43,11 @@ import smithereen.model.SignupInvitation; import smithereen.model.PaginatedList; import smithereen.model.User; +import smithereen.model.UserBanStatus; import smithereen.model.UserNotifications; import smithereen.model.UserPrivacySettingKey; +import smithereen.model.UserRole; +import smithereen.model.media.MediaFileRecord; import smithereen.storage.sql.DatabaseConnection; import smithereen.storage.sql.DatabaseConnectionManager; import smithereen.storage.sql.SQLQueryBuilder; @@ -54,7 +61,6 @@ public class UserStorage{ private static LruCache cacheByActivityPubID=new LruCache<>(500); private static LruCache accountCache=new LruCache<>(500); private static final LruCache birthdayReminderCache=new LruCache<>(500); - private static final LruCache eventReminderCache=new LruCache<>(500); public static synchronized User getById(int id) throws SQLException{ User user=cache.get(id); @@ -65,6 +71,11 @@ public static synchronized User getById(int id) throws SQLException{ .where("id=?", id) .executeAndGetSingleObject(User::fromResultSet); if(user!=null){ + if(user.icon!=null && !user.icon.isEmpty() && user.icon.getFirst() instanceof LocalImage li){ + MediaFileRecord mfr=MediaStorage.getMediaFileRecord(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } cache.put(id, user); cacheByUsername.put(user.getFullUsername(), user); } @@ -111,8 +122,23 @@ public static Map getById(Collection _ids) throws SQLExc .whereIn("id", ids) .executeAsStream(User::fromResultSet) .forEach(u->result.put(u.id, u)); + Set needAvatars=result.values().stream() + .map(u->u.icon!=null && !u.icon.isEmpty() && u.icon.getFirst() instanceof LocalImage li ? li : null) + .filter(li->li!=null && li.fileRecord==null) + .map(li->li.fileID) + .collect(Collectors.toSet()); + if(!needAvatars.isEmpty()){ + Map records=MediaStorage.getMediaFileRecords(needAvatars); + for(User user:result.values()){ + if(user.icon!=null && !user.icon.isEmpty() && user.icon.getFirst() instanceof LocalImage li && li.fileRecord==null){ + MediaFileRecord mfr=records.get(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } + } + } synchronized(UserStorage.class){ - for(int id: ids){ + for(int id:ids){ User u=result.get(id); if(u!=null) putIntoCache(u); @@ -141,8 +167,14 @@ public static synchronized User getByUsername(@NotNull String username) throws S .allColumns() .where("username=? AND domain=?", realUsername, domain) .executeAndGetSingleObject(User::fromResultSet); - if(user!=null) + if(user!=null){ + if(user.icon!=null && !user.icon.isEmpty() && user.icon.getFirst() instanceof LocalImage li){ + MediaFileRecord mfr=MediaStorage.getMediaFileRecord(li.fileID); + if(mfr!=null) + li.fillIn(mfr); + } putIntoCache(user); + } return user; } @@ -331,6 +363,14 @@ public static int getUserFriendsCount(int userID) throws SQLException{ .executeAndGetInt(); } + public static int getUserFollowerOrFollowingCount(int userID, boolean followers) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("followings") + .count() + .where((followers ? "followee_id" : "follower_id")+"=? AND accepted=1 AND mutual=0", userID) + .executeAndGetInt(); + } + public static PaginatedList getRandomFriendsForProfile(int userID, int count) throws SQLException{ try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ int total=new SQLQueryBuilder(conn) @@ -680,6 +720,7 @@ public static synchronized int putOrUpdateForeignUser(ForeignUser user) throws S bldr.executeNoResult(); } user.id=existingUserID; + user.lastUpdated=Instant.now(); putIntoCache(user); if(isNew){ @@ -693,6 +734,28 @@ public static synchronized int putOrUpdateForeignUser(ForeignUser user) throws S } return existingUserID; + }catch(SQLIntegrityConstraintViolationException x){ + // Rare case: user with a matching username@domain but a different AP ID already exists + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + int oldID=new SQLQueryBuilder(conn) + .selectFrom("users") + .columns("id") + .where("username=? AND domain=? AND ap_id<>?", user.username, user.domain, user.activityPubID) + .executeAndGetInt(); + if(oldID<=0){ + LOG.warn("Didn't find an existing user with username {}@{} while trying to deduplicate {}", user.username, user.domain, user.activityPubID); + throw x; + } + LOG.info("Deduplicating user rows: username {}@{}, old local ID {}, new AP ID {}", user.username, user.domain, oldID, user.activityPubID); + // Assign a temporary random username to this existing user row to get it out of the way + new SQLQueryBuilder(conn) + .update("users") + .value("username", UUID.randomUUID().toString()) + .where("id=?", oldID) + .executeNoResult(); + // Try again + return putOrUpdateForeignUser(user); + } } } @@ -817,10 +880,6 @@ public static List getAllAccounts(int offset, int count) throws SQLExce try(ResultSet res=stmt.executeQuery()){ while(res.next()){ Account acc=Account.fromResultSet(res); - int inviterID=res.getInt("inviter_user_id"); - if(inviterID!=0){ - acc.invitedBy=getById(inviterID); - } accounts.add(acc); } } @@ -833,15 +892,11 @@ public static synchronized Account getAccount(int id) throws SQLException{ if(acc!=null) return acc; try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ - PreparedStatement stmt=conn.prepareStatement("SELECT a1.*, a2.user_id AS inviter_user_id FROM accounts AS a1 LEFT JOIN accounts AS a2 ON a1.invited_by=a2.id WHERE a1.id=?"); + PreparedStatement stmt=conn.prepareStatement("SELECT * FROM accounts WHERE id=?"); stmt.setInt(1, id); try(ResultSet res=stmt.executeQuery()){ if(res.next()){ acc=Account.fromResultSet(res); - int inviterID=res.getInt("inviter_user_id"); - if(inviterID!=0){ - acc.invitedBy=getById(inviterID); - } accountCache.put(acc.id, acc); return acc; } @@ -850,22 +905,44 @@ public static synchronized Account getAccount(int id) throws SQLException{ return null; } - public static void setAccountAccessLevel(int id, Account.AccessLevel level) throws SQLException{ + public static Map getAccounts(Collection ids) throws SQLException{ + return new SQLQueryBuilder() + .selectFrom("accounts") + .allColumns() + .whereIn("id", ids) + .executeAsStream(Account::fromResultSet) + .collect(Collectors.toMap(a->a.id, Function.identity())); + } + + public static void setAccountRole(Account account, int role, int promotedBy) throws SQLException{ new SQLQueryBuilder() .update("accounts") - .value("access_level", level) - .where("id=?", id) + .value("role", role>0 ? role : null) + .value("promoted_by", promotedBy>0 ? promotedBy : null) + .where("id=?", account.id) .executeNoResult(); synchronized(UserStorage.class){ - accountCache.remove(id); + accountCache.remove(account.id); } + SessionStorage.removeFromUserPermissionsCache(account.user.id); + } + + public static synchronized void resetAccountsCache(){ + accountCache.evictAll(); } public static List getAdmins() throws SQLException{ + Set rolesToShow=Config.userRoles.values() + .stream() + .filter(r->r.permissions().contains(UserRole.Permission.VISIBLE_IN_STAFF)) + .map(UserRole::id) + .collect(Collectors.toSet()); + if(rolesToShow.isEmpty()) + return List.of(); return getByIdAsList(new SQLQueryBuilder() .selectFrom("accounts") .columns("user_id") - .where("access_level=?", Account.AccessLevel.ADMIN) + .whereIn("role", rolesToShow) .executeAndGetIntList()); } @@ -1183,4 +1260,48 @@ public static void deleteUser(User user) throws SQLException{ .executeNoResult(); removeFromCache(user); } + + public static void deleteAccount(Account account) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + // Delete media file refs first because triggers don't trigger on cascade deletes. Argh. + new SQLQueryBuilder(conn) + .deleteFrom("media_file_refs") + .where("owner_user_id=?", account.user.id) + .executeNoResult(); + + new SQLQueryBuilder(conn) + .deleteFrom("accounts") + .where("id=?", account.id) + .executeNoResult(); + new SQLQueryBuilder(conn) + .deleteFrom("users") + .where("id=?", account.user.id) + .executeNoResult(); + removeFromCache(account.user); + accountCache.remove(account.id); + } + } + + public static void removeAccountFromCache(int id){ + accountCache.remove(id); + } + + public static void setUserBanStatus(User user, Account userAccount, UserBanStatus status, String banInfo) throws SQLException{ + new SQLQueryBuilder() + .update("users") + .value("ban_status", status) + .value("ban_info", banInfo) + .where("id=?", user.id) + .executeNoResult(); + removeFromCache(user); + accountCache.remove(userAccount.id); + } + + public static List getTerminallyBannedUsers() throws SQLException{ + return getByIdAsList(new SQLQueryBuilder() + .selectFrom("users") + .columns("id") + .whereIn("ban_status", UserBanStatus.SELF_DEACTIVATED, UserBanStatus.SUSPENDED) + .executeAndGetIntList()); + } } diff --git a/src/main/java/smithereen/storage/media/LocalMediaFileStorageDriver.java b/src/main/java/smithereen/storage/media/LocalMediaFileStorageDriver.java new file mode 100644 index 00000000..256f1194 --- /dev/null +++ b/src/main/java/smithereen/storage/media/LocalMediaFileStorageDriver.java @@ -0,0 +1,54 @@ +package smithereen.storage.media; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Locale; + +import smithereen.Config; +import smithereen.Utils; +import smithereen.model.ObfuscatedObjectIDType; +import smithereen.model.media.MediaFileID; +import smithereen.model.media.MediaFileRecord; +import smithereen.storage.ImgProxy; +import smithereen.util.XTEA; + +public class LocalMediaFileStorageDriver extends MediaFileStorageDriver{ + @Override + public void storeFile(File localFile, MediaFileID id) throws IOException{ + int oid=Math.abs(id.originalOwnerID()); + File dir=new File(Config.uploadPath, String.format(Locale.US, "%02d/%02d/%02d", oid%100, oid/100%100, oid/10000%100)); + if(!dir.exists() && !dir.mkdirs()) + throw new IOException("Failed to create directories"); + File targetFile=new File(Config.uploadPath, getFilePath(id)); + Files.move(localFile.toPath(), targetFile.toPath()); + } + + @Override + public InputStream openStream(MediaFileID id) throws IOException{ + return new FileInputStream(getFilePath(id)); + } + + @Override + public void deleteFile(MediaFileID id) throws IOException{ + Files.deleteIfExists(Path.of(getFilePath(id))); + } + + @Override + public ImgProxy.UrlBuilder getImgProxyURL(MediaFileID id){ + String url="local://"+Config.imgproxyLocalUploads+"/"+getFilePath(id); + return new ImgProxy.UrlBuilder(url); + } + + private String getFilePath(MediaFileID id){ + int oid=Math.abs(id.originalOwnerID()); + return String.format(Locale.US, "%02d/%02d/%02d/", oid%100, oid/100%100, oid/10000%100)+ + Base64.getUrlEncoder().withoutPadding().encodeToString(id.randomID())+"_"+ + Base64.getUrlEncoder().withoutPadding().encodeToString(Utils.packLong(XTEA.obfuscateObjectID(id.id(), ObfuscatedObjectIDType.MEDIA_FILE)))+ + "."+id.type().getFileExtension(); + } +} diff --git a/src/main/java/smithereen/storage/media/MediaFileStorageDriver.java b/src/main/java/smithereen/storage/media/MediaFileStorageDriver.java new file mode 100644 index 00000000..cccee712 --- /dev/null +++ b/src/main/java/smithereen/storage/media/MediaFileStorageDriver.java @@ -0,0 +1,49 @@ +package smithereen.storage.media; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import smithereen.Config; +import smithereen.model.media.MediaFileID; +import smithereen.model.media.MediaFileRecord; +import smithereen.storage.ImgProxy; + +public abstract class MediaFileStorageDriver{ + protected static final Logger LOG=LoggerFactory.getLogger(MediaFileStorageDriver.class); + private static MediaFileStorageDriver instance; + + public abstract void storeFile(File localFile, MediaFileID id) throws IOException; + public abstract InputStream openStream(MediaFileID id) throws IOException; + public abstract void deleteFile(MediaFileID id) throws IOException; + public abstract ImgProxy.UrlBuilder getImgProxyURL(MediaFileID id); + + public Set deleteFiles(Collection ids){ + HashSet deletedIDs=new HashSet<>(); + for(MediaFileID id:ids){ + try{ + deleteFile(id); + deletedIDs.add(id); + }catch(IOException x){ + LOG.warn("Failed to delete file {}", id, x); + } + } + return deletedIDs; + } + + public static MediaFileStorageDriver getInstance(){ + if(instance==null){ + instance=switch(Config.storageBackend){ + case LOCAL -> new LocalMediaFileStorageDriver(); + case S3 -> new S3MediaFileStorageDriver(Config.s3Configuration); + }; + } + return instance; + } +} diff --git a/src/main/java/smithereen/storage/media/S3MediaFileStorageDriver.java b/src/main/java/smithereen/storage/media/S3MediaFileStorageDriver.java new file mode 100644 index 00000000..bebe8009 --- /dev/null +++ b/src/main/java/smithereen/storage/media/S3MediaFileStorageDriver.java @@ -0,0 +1,458 @@ +package smithereen.storage.media; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.xml.parsers.DocumentBuilder; + +import smithereen.Config; +import smithereen.Utils; +import smithereen.http.ExtendedHttpClient; +import smithereen.model.ObfuscatedObjectIDType; +import smithereen.model.media.MediaFileID; +import smithereen.storage.ImgProxy; +import smithereen.storage.utils.Pair; +import smithereen.util.UriBuilder; +import smithereen.util.XTEA; +import smithereen.util.XmlParser; + +public class S3MediaFileStorageDriver extends MediaFileStorageDriver{ + private static final Logger LOG=LoggerFactory.getLogger(S3MediaFileStorageDriver.class); + private static final DateTimeFormatter DATE_TIME_FORMATTER=DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"); + private static final DateTimeFormatter DATE_FORMATTER=DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final String XMLNS="http://s3.amazonaws.com/doc/2006-03-01/"; + + private final Config.S3Configuration config; + private final String publicHostname, apiHostname; + private final HttpClient httpClient; + + public S3MediaFileStorageDriver(Config.S3Configuration config){ + this.config=config; + if(config.aliasHost()!=null){ + publicHostname=config.aliasHost(); + }else if(config.hostname()!=null){ + publicHostname=config.hostname(); + }else{ + publicHostname="s3-"+config.region()+".amazonaws.com"; + } + if(config.endpoint()!=null){ + apiHostname=config.endpoint(); + }else{ + apiHostname="s3."+config.region()+".amazonaws.com"; + } + LOG.trace("Loaded configuration: {}", config.toString().replace(config.secretKey(), "***")); + + httpClient=ExtendedHttpClient.newHttpClient(); + } + + @Override + public void storeFile(File localFile, MediaFileID id) throws IOException{ + HttpRequest req=HttpRequest.newBuilder(getApiUrl().appendPath(getObjectName(id)).build()) + .PUT(HttpRequest.BodyPublishers.ofFile(localFile.toPath())) + .header("Content-Type", id.type().getMimeType()) + .build(); + try(FileInputStream in=new FileInputStream(localFile)){ + req=signRequest(req, in); + } + executeRequest(req, HttpResponse.BodyHandlers.ofString()); + localFile.delete(); + } + + @Override + public InputStream openStream(MediaFileID id) throws IOException{ + HttpRequest req=HttpRequest.newBuilder(getObjectURL(id)).build(); + return executeRequest(req, HttpResponse.BodyHandlers.ofInputStream()).body(); + } + + @Override + public void deleteFile(MediaFileID id) throws IOException{ + HttpRequest req=HttpRequest.newBuilder(getApiUrl().appendPath(getObjectName(id)).build()) + .DELETE() + .build(); + req=signRequest(req, null); + executeRequest(req, HttpResponse.BodyHandlers.ofString()); + } + + @Override + public Set deleteFiles(Collection ids){ + if(ids.isEmpty()){ + return Set.of(); + } + if(ids.size()==1){ + MediaFileID id=ids.iterator().next(); + try{ + deleteFile(id); + return Set.of(id); + }catch(IOException x){ + LOG.warn("Failed to delete file {}", id, x); + return Set.of(); + } + } + HashSet deletedIDs=new HashSet<>(); + Document doc=XmlParser.newDocumentBuilder().newDocument(); + Element root=doc.createElementNS(XMLNS, "Delete"); + doc.appendChild(root); + HashMap idsByKey=new HashMap<>(); + for(MediaFileID id:ids){ + String key=getObjectName(id); + idsByKey.put(key, id); + Element objEl=doc.createElement("Object"); + Element keyEl=doc.createElement("Key"); + keyEl.setTextContent(key); + objEl.appendChild(keyEl); + root.appendChild(objEl); + } + String xmlStr=XmlParser.serialize(doc); + HttpRequest req=HttpRequest.newBuilder(getApiUrl().queryParam("delete", "").build()) + .POST(HttpRequest.BodyPublishers.ofString(xmlStr)) + .header("Content-MD5", md5Base64(xmlStr)) + .build(); + try{ + req=signRequest(req, xmlStr); + }catch(IOException x){ // Shouldn't happen anyway + throw new RuntimeException(x); + } + try(InputStream in=executeRequest(req, HttpResponse.BodyHandlers.ofInputStream()).body()){ + Document resp=XmlParser.newDocumentBuilder().parse(in); + Element respEl=resp.getDocumentElement(); + if("DeleteResult".equals(respEl.getTagName())){ + for(Node child:XmlParser.iterateNodes(respEl.getChildNodes())){ + if(!(child instanceof Element el)) + continue; + switch(el.getTagName()){ + case "Deleted" -> { + if(el.getElementsByTagName("Key").item(0) instanceof Element keyEl){ + MediaFileID id=idsByKey.get(keyEl.getTextContent()); + LOG.trace("File {} deleted successfully", id); + if(id!=null) + deletedIDs.add(id); + } + } + case "Error" -> { + if(el.getElementsByTagName("Key").item(0) instanceof Element keyEl){ + LOG.warn("Failed to delete file {}: {}", keyEl.getTextContent(), getServerErrorMessage(el)); + } + } + } + } + }else{ + LOG.warn("Unexpected tag name in bulk delete result: {}", respEl.getTagName()); + } + }catch(IOException | SAXException x){ + LOG.warn("Failed to bulk delete files", x); + return deletedIDs; + } + + return deletedIDs; + } + + @Override + public ImgProxy.UrlBuilder getImgProxyURL(MediaFileID id){ + return new ImgProxy.UrlBuilder(getObjectURL(id).toString()); + } + + private URI getObjectURL(MediaFileID id){ + UriBuilder url=new UriBuilder() + .scheme(config.protocol()) + .authority(publicHostname); + if(config.aliasHost()==null) + url.appendPath(config.bucket()); + url.appendPath(getObjectName(id)); + return url.build(); + } + + private UriBuilder getApiUrl(){ + UriBuilder url=new UriBuilder() + .scheme(config.protocol()); + if(config.endpoint()==null || config.overridePathStyle()) + url.authority(config.bucket()+"."+apiHostname); + else + url.authority(apiHostname).appendPath(config.bucket()); + return url; + } + + private String getObjectName(MediaFileID id){ + return Base64.getUrlEncoder().withoutPadding().encodeToString(id.randomID())+"_"+ + Base64.getUrlEncoder().withoutPadding().encodeToString(Utils.packLong(XTEA.obfuscateObjectID(id.id(), ObfuscatedObjectIDType.MEDIA_FILE)))+ + "."+id.type().getFileExtension(); + } + + private HttpResponse executeRequest(HttpRequest req, HttpResponse.BodyHandler bodyHandler) throws IOException{ + LOG.trace("Executing request: {}", req); + try{ + HttpResponse resp=httpClient.send(req, bodyHandler); + LOG.trace("Response: {}", resp); + if(resp.statusCode()/100!=2){ + String contentType=resp.headers().firstValue("content-type").orElse(""); + if(contentType.startsWith("application/xml") || contentType.startsWith("text/xml")){ + String bodyStr=switch(resp.body()){ + case String s -> s; + case byte[] ba -> new String(ba, StandardCharsets.UTF_8); + case InputStream in -> { + BufferedReader reader=new BufferedReader(new InputStreamReader(in)); + StringBuilder sb=new StringBuilder(); + String line; + while((line=reader.readLine())!=null){ + sb.append(line); + } + yield sb.toString(); + } + default -> throw defaultException(resp); + }; + LOG.debug("Response: {}, body: {}", resp, bodyStr); + DocumentBuilder builder=XmlParser.newDocumentBuilder(); + try{ + Document doc=builder.parse(new InputSource(new StringReader(bodyStr))); + Element docEl=doc.getDocumentElement(); + if("Error".equals(docEl.getTagName())){ + String fullMessage=getServerErrorMessage(docEl); + if(fullMessage.isEmpty()) + throw defaultException(resp); + throw new RemoteServerException(fullMessage); + }else{ + throw defaultException(resp); + } + }catch(SAXException x){ + throw defaultException(resp); + } + }else{ + throw defaultException(resp); + } + } + return resp; + }catch(InterruptedException e){ + throw new RuntimeException(e); + } + } + + private String getServerErrorMessage(Element errorEl){ + String code=switch(errorEl.getElementsByTagName("Code").item(0)){ + case Element el -> el.getTextContent(); + case null, default -> null; + }; + String message=switch(errorEl.getElementsByTagName("Message").item(0)){ + case Element el -> el.getTextContent(); + case null, default -> null; + }; + return Stream.of(code, message).filter(Objects::nonNull).collect(Collectors.joining(": ")); + } + + private RemoteServerException defaultException(HttpResponse resp){ + return new RemoteServerException("Response was not successful: status "+resp.statusCode()); + } + + private static String uriEncode(String input){ + StringBuilder sb=new StringBuilder(); + HexFormat hex=HexFormat.of().withPrefix("%").withUpperCase(); + byte[] bytes=input.getBytes(StandardCharsets.UTF_8); + int i=0; + for(byte b:bytes){ + int chr=b & 0xFF; + if((chr>='A' && chr<='Z') || (chr>='a' && chr <='z') || (chr>='0' && chr<='9') || chr=='-' || chr=='.' || chr=='_' || chr=='~'){ + sb.append((char)chr); + }else{ + hex.formatHex(sb, bytes, i, i+1); + } + i++; + } + return sb.toString(); + } + + private static byte[] hmacSha256(byte[] key, String input){ + try{ + Mac mac=Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(input.getBytes(StandardCharsets.UTF_8)); + }catch(NoSuchAlgorithmException | InvalidKeyException x){ + throw new RuntimeException(x); + } + } + + private static String sha256(String input){ + try{ + MessageDigest md=MessageDigest.getInstance("SHA256"); + return Utils.byteArrayToHexString(md.digest(input.getBytes(StandardCharsets.UTF_8))); + }catch(NoSuchAlgorithmException x){ + throw new RuntimeException(x); + } + } + + private static String md5Base64(String input){ + try{ + MessageDigest md=MessageDigest.getInstance("MD5"); + return Base64.getEncoder().encodeToString(md.digest(input.getBytes(StandardCharsets.UTF_8))); + }catch(NoSuchAlgorithmException x){ + throw new RuntimeException(x); + } + } + + private HttpRequest signRequest(HttpRequest req, Object body) throws IOException{ + URI uri=req.uri(); + // https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html + /* + Step 1: Create a canonical request + + \n + \n + \n + \n + \n + + */ + StringBuilder canonicalRequest=new StringBuilder(req.method().toUpperCase()); + canonicalRequest.append('\n'); + String path=uri.getPath(); + if("/".equals(path)){ + canonicalRequest.append("/\n"); + }else{ + int i=0; + for(String segment:path.substring(1).split("/")){ + //if(i>0) + canonicalRequest.append('/'); + canonicalRequest.append(uriEncode(segment)); + i++; + } + canonicalRequest.append('\n'); + } + canonicalRequest.append(UriBuilder.parseQueryString(uri.getRawQuery()) + .entrySet() + .stream() + .map(e->new Pair<>(uriEncode(e.getKey()), uriEncode(e.getValue()))) + .sorted(Comparator.comparing(Pair::first)) + .map(e->e.first()+"="+e.second()) + .collect(Collectors.joining("&"))); + canonicalRequest.append('\n'); + + HashMap headers=new HashMap<>(); + String contentHash; + LocalDateTime now=LocalDateTime.now(ZoneId.of("UTC")); + String dateTime=DATE_TIME_FORMATTER.format(now); + String date=DATE_FORMATTER.format(now); + if(body instanceof InputStream in){ + try{ + MessageDigest md=MessageDigest.getInstance("SHA256"); + byte[] buf=new byte[8192]; + int read; + while((read=in.read(buf))>0){ + md.update(buf, 0, read); + } + contentHash=Utils.byteArrayToHexString(md.digest()); + }catch(NoSuchAlgorithmException x){ + throw new RuntimeException(x); + } + }else if(body instanceof String str){ + contentHash=sha256(str); + }else{ + contentHash="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; // sha256 of empty string + } + headers.put("x-amz-content-sha256", contentHash); + headers.put("host", uri.getAuthority()); + req.headers() + .map() + .entrySet() + .stream() + .filter(e->{ + String key=e.getKey().toLowerCase(); + return key.equals("content-type") || key.startsWith("x-amz"); + }) + .forEach(e->headers.put(e.getKey().toLowerCase(), String.join(",", e.getValue()))); + canonicalRequest.append(headers.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .map(e->e.getKey()+":"+e.getValue().trim()) + .collect(Collectors.joining("\n"))); + canonicalRequest.append("\n\n"); + String signedHeaders=headers.keySet().stream().sorted().collect(Collectors.joining(";")); + canonicalRequest.append(signedHeaders); + canonicalRequest.append('\n'); + canonicalRequest.append(contentHash); + + // Step 2: Create a hash of the canonical request + String canonicalRequestHash=sha256(canonicalRequest.toString()); + + /* + Step 3: Create a string to sign + + Algorithm \n + RequestDateTime \n + CredentialScope \n + HashedCanonicalRequest + */ + String strToSign="AWS4-HMAC-SHA256\n"+dateTime+ + '\n'+ + date+ + '/'+ + config.region()+ + "/s3/aws4_request\n"+ + canonicalRequestHash; + + // Step 4: Calculate the signature + byte[] dateKey=hmacSha256(("AWS4"+config.secretKey()).getBytes(StandardCharsets.UTF_8), date); + byte[] dateRegionKey=hmacSha256(dateKey, config.region()); + byte[] dateRegionServiceKey=hmacSha256(dateRegionKey, "s3"); + byte[] signingKey=hmacSha256(dateRegionServiceKey, "aws4_request"); + byte[] signature=hmacSha256(signingKey, strToSign); + + // Step 5: Add the signature to the request + return HttpRequest.newBuilder(req, (n, v)->true) + .header("x-amz-content-sha256", contentHash) + .header("x-amz-date", dateTime) + .header("Authorization", "AWS4-HMAC-SHA256 Credential="+config.keyID()+"/"+date+"/"+config.region()+"/s3/aws4_request, SignedHeaders="+signedHeaders+", Signature="+Utils.byteArrayToHexString(signature)) + .build(); + } + + public static class RemoteServerException extends IOException{ + public RemoteServerException(){ + super(); + } + + public RemoteServerException(String message){ + super(message); + } + + public RemoteServerException(String message, Throwable cause){ + super(message, cause); + } + + public RemoteServerException(Throwable cause){ + super(cause); + } + } +} diff --git a/src/main/java/smithereen/storage/sql/DatabaseConnectionManager.java b/src/main/java/smithereen/storage/sql/DatabaseConnectionManager.java index ad297e90..2d8259d6 100644 --- a/src/main/java/smithereen/storage/sql/DatabaseConnectionManager.java +++ b/src/main/java/smithereen/storage/sql/DatabaseConnectionManager.java @@ -6,14 +6,16 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; -import java.util.LinkedList; +import java.util.ArrayList; import smithereen.Config; public class DatabaseConnectionManager{ private static final Logger LOG=LoggerFactory.getLogger(DatabaseConnectionManager.class); - private static final LinkedList pool=new LinkedList<>(); + private static final ArrayList pool=new ArrayList<>(); private static final ThreadLocal currentThreadConnection=new ThreadLocal<>(); + private static final ArrayList connectionsInUse=new ArrayList<>(); + private static final boolean DEBUG_CONNECTION_LEAKS=System.getProperty("smithereen.debugDatabaseConnections")!=null; public static synchronized DatabaseConnection getConnection() throws SQLException{ DatabaseConnection conn; @@ -23,9 +25,12 @@ public static synchronized DatabaseConnection getConnection() throws SQLExceptio return conn; } if(pool.isEmpty()){ - conn=new DatabaseConnection(newConnection()); + if(DEBUG_CONNECTION_LEAKS) + conn=new DebugDatabaseConnection(newConnection()); + else + conn=new DatabaseConnection(newConnection()); }else{ - conn=pool.removeFirst(); + conn=pool.removeLast(); try{ validateConnection(conn.actualConnection); conn.lastUsed=System.nanoTime(); @@ -39,6 +44,9 @@ public static synchronized DatabaseConnection getConnection() throws SQLExceptio conn.useDepth++; conn.ownerThread=Thread.currentThread(); currentThreadConnection.set(conn); + connectionsInUse.add(conn); + if(DEBUG_CONNECTION_LEAKS && conn instanceof DebugDatabaseConnection ddc) + ddc.throwableForStack=new Exception().fillInStackTrace(); return conn; } @@ -50,6 +58,7 @@ static synchronized void reuseConnection(DatabaseConnection conn){ conn.ownerThread=null; currentThreadConnection.remove(); pool.add(conn); + connectionsInUse.remove(conn); LOG.trace("Reusing database connection. Pool size is {}", pool.size()); } } @@ -72,6 +81,8 @@ public static synchronized void closeUnusedConnections(){ int size=pool.size(); boolean removedAny=pool.removeIf(conn->{ if(System.nanoTime()-conn.lastUsed>5*60_000_000_000L){ + if(conn.useDepth!=0) + throw new IllegalStateException("Connection use depth is "+conn.useDepth+", expected 0"); try{ conn.actualConnection.close(); }catch(SQLException ignore){} @@ -82,5 +93,12 @@ public static synchronized void closeUnusedConnections(){ if(removedAny){ LOG.debug("Closed {} connections, pool size is {}", size-pool.size(), pool.size()); } + for(DatabaseConnection conn:connectionsInUse){ + if(System.nanoTime()-conn.lastUsed>60_000_000_000L){ + LOG.warn("Database connection {} was not closed! Owner: {}", conn, conn.ownerThread); + if(conn instanceof DebugDatabaseConnection ddc) + LOG.warn("Last opened at:", ddc.throwableForStack); + } + } } } diff --git a/src/main/java/smithereen/storage/sql/DebugDatabaseConnection.java b/src/main/java/smithereen/storage/sql/DebugDatabaseConnection.java new file mode 100644 index 00000000..310f8b3d --- /dev/null +++ b/src/main/java/smithereen/storage/sql/DebugDatabaseConnection.java @@ -0,0 +1,11 @@ +package smithereen.storage.sql; + +import java.sql.Connection; + +public class DebugDatabaseConnection extends DatabaseConnection{ + Throwable throwableForStack; + + public DebugDatabaseConnection(Connection actualConnection){ + super(actualConnection); + } +} diff --git a/src/main/java/smithereen/storage/sql/SQLQueryBuilder.java b/src/main/java/smithereen/storage/sql/SQLQueryBuilder.java index 03079529..cb13a69e 100644 --- a/src/main/java/smithereen/storage/sql/SQLQueryBuilder.java +++ b/src/main/java/smithereen/storage/sql/SQLQueryBuilder.java @@ -191,6 +191,7 @@ public SQLQueryBuilder onDuplicateKeyUpdate(){ public void executeNoResult() throws SQLException{ try(PreparedStatement stmt=createStatementInternal(0)){ stmt.execute(); + }finally{ if(needCloseConnection) conn.close(); } @@ -199,9 +200,10 @@ public void executeNoResult() throws SQLException{ public int executeUpdate() throws SQLException{ try(PreparedStatement stmt=createStatementInternal(0)){ int r=stmt.executeUpdate(); + return r; + }finally{ if(needCloseConnection) conn.close(); - return r; } } @@ -209,9 +211,10 @@ public int executeAndGetID() throws SQLException{ try(PreparedStatement stmt=createStatementInternal(Statement.RETURN_GENERATED_KEYS)){ stmt.execute(); int id=DatabaseUtils.oneFieldToInt(stmt.getGeneratedKeys()); + return id; + }finally{ if(needCloseConnection) conn.close(); - return id; } } @@ -219,9 +222,10 @@ public long executeAndGetIDLong() throws SQLException{ try(PreparedStatement stmt=createStatementInternal(Statement.RETURN_GENERATED_KEYS)){ stmt.execute(); long id=DatabaseUtils.oneFieldToLong(stmt.getGeneratedKeys()); + return id; + }finally{ if(needCloseConnection) conn.close(); - return id; } } @@ -242,9 +246,10 @@ public Stream executeAsStream(ResultSetDeserializerFunction creator) t public T executeAndGetSingleObject(ResultSetDeserializerFunction creator) throws SQLException{ try(PreparedStatement stmt=createStatementInternal(0); ResultSet res=stmt.executeQuery()){ T result=res.next() ? creator.deserialize(res) : null; + return result; + }finally{ if(needCloseConnection) conn.close(); - return result; } } @@ -260,14 +265,18 @@ public int executeAndGetInt() throws SQLException{ public List executeAndGetIntList() throws SQLException{ try(PreparedStatement stmt=createStatementInternal(0)){ List r=DatabaseUtils.intResultSetToList(stmt.executeQuery()); + return r; + }finally{ if(needCloseConnection) conn.close(); - return r; } } public IntStream executeAndGetIntStream() throws SQLException{ - return DatabaseUtils.intResultSetToStream(createStatementInternal(0).executeQuery()); + return DatabaseUtils.intResultSetToStream(createStatementInternal(0).executeQuery(), ()->{ + if(needCloseConnection) + conn.close(); + }); } private void appendSelectColumns(StringBuilder sb){ diff --git a/src/main/java/smithereen/storage/utils/IntPair.java b/src/main/java/smithereen/storage/utils/IntPair.java new file mode 100644 index 00000000..fa323900 --- /dev/null +++ b/src/main/java/smithereen/storage/utils/IntPair.java @@ -0,0 +1,4 @@ +package smithereen.storage.utils; + +public record IntPair(int first, int second){ +} diff --git a/src/main/java/smithereen/storage/utils/Pair.java b/src/main/java/smithereen/storage/utils/Pair.java new file mode 100644 index 00000000..498d4e14 --- /dev/null +++ b/src/main/java/smithereen/storage/utils/Pair.java @@ -0,0 +1,4 @@ +package smithereen.storage.utils; + +public record Pair(F first, S second){ +} diff --git a/src/main/java/smithereen/templates/AddQueryParamsFunction.java b/src/main/java/smithereen/templates/AddQueryParamsFunction.java index 88dab639..4486e781 100644 --- a/src/main/java/smithereen/templates/AddQueryParamsFunction.java +++ b/src/main/java/smithereen/templates/AddQueryParamsFunction.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Map; -import smithereen.model.UriBuilder; +import smithereen.util.UriBuilder; public class AddQueryParamsFunction implements Function{ @Override diff --git a/src/main/java/smithereen/templates/LangFunction.java b/src/main/java/smithereen/templates/LangFunction.java index e36a9aef..ef683888 100644 --- a/src/main/java/smithereen/templates/LangFunction.java +++ b/src/main/java/smithereen/templates/LangFunction.java @@ -22,7 +22,7 @@ public Object execute(Map args, PebbleTemplate self, EvaluationC String key=(String) args.get("key"); if(args.size()==1){ - return new SafeString(Lang.get(locale).get(key)); + return new SafeString(nl2br(Lang.get(locale).get(key))); }else{ if(!(args.get("vars") instanceof Map)){ throw new IllegalArgumentException("wrong arg types "+args); @@ -32,9 +32,9 @@ public Object execute(Map args, PebbleTemplate self, EvaluationC String formatted=Lang.get(locale).get(key, formatArgs); if(args.get("links") instanceof Map links){ //noinspection unchecked - return new SafeString(Utils.substituteLinks(formatted, links)); + return new SafeString(nl2br(Utils.substituteLinks(formatted, links))); }else{ - return new SafeString(formatted); + return new SafeString(nl2br(formatted)); } } } @@ -43,4 +43,8 @@ public Object execute(Map args, PebbleTemplate self, EvaluationC public List getArgumentNames(){ return List.of("key", "vars", "links"); } + + private String nl2br(String in){ + return in.replace("\n", "
"); + } } diff --git a/src/main/java/smithereen/templates/ProfileRelFunction.java b/src/main/java/smithereen/templates/ProfileRelFunction.java new file mode 100644 index 00000000..39c41289 --- /dev/null +++ b/src/main/java/smithereen/templates/ProfileRelFunction.java @@ -0,0 +1,45 @@ +package smithereen.templates; + +import java.util.List; +import java.util.Map; + +import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.extension.escaper.SafeString; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import smithereen.activitypub.objects.Actor; +import smithereen.model.ForeignGroup; +import smithereen.model.ForeignUser; +import smithereen.model.Group; +import smithereen.model.User; + +public class ProfileRelFunction implements Function{ + + @Override + public Object execute(Map args, PebbleTemplate self, EvaluationContext context, int lineNumber){ + Actor actor=null; + Object arg=args.get("actor"); + if(arg instanceof Actor a){ + actor=a; + }else if(arg instanceof Integer id){ + if(id>0){ + Map users=(Map) context.getVariable("users"); + actor=users.get(id); + }else{ + Map groups=(Map) context.getVariable("groups"); + actor=groups.get(-id); + } + } + + if(actor instanceof ForeignUser || actor instanceof ForeignGroup){ + return new SafeString(" rel=\"nofollow\""); + } + + return ""; + } + + @Override + public List getArgumentNames(){ + return List.of("actor"); + } +} diff --git a/src/main/java/smithereen/templates/ProfileUrlFunction.java b/src/main/java/smithereen/templates/ProfileUrlFunction.java index 45e3a8dd..6034cb61 100644 --- a/src/main/java/smithereen/templates/ProfileUrlFunction.java +++ b/src/main/java/smithereen/templates/ProfileUrlFunction.java @@ -12,7 +12,7 @@ public class ProfileUrlFunction implements Function{ @Override public Object execute(Map args, PebbleTemplate self, EvaluationContext context, int lineNumber){ - int id=(Integer)args.get("id"); + int id=((Number)args.get("id")).intValue(); if(id>0){ Map users=(Map) context.getVariable("users"); if(users==null) diff --git a/src/main/java/smithereen/templates/RenderAttachmentsFunction.java b/src/main/java/smithereen/templates/RenderAttachmentsFunction.java index 3c55b86d..606f34ff 100644 --- a/src/main/java/smithereen/templates/RenderAttachmentsFunction.java +++ b/src/main/java/smithereen/templates/RenderAttachmentsFunction.java @@ -34,6 +34,10 @@ import spark.utils.StringUtils; public class RenderAttachmentsFunction implements Function{ + public static final int MAX_WIDTH=1000; + public static final int MAX_HEIGHT=1777; // 9:16 + public static final int MIN_HEIGHT=475; // ~2:1 + public static final float GAP=1.5f; @Override public Object execute(Map args, PebbleTemplate self, EvaluationContext context, int lineNumber){ @@ -50,13 +54,13 @@ public Object execute(Map args, PebbleTemplate self, EvaluationC } } ArrayList lines=new ArrayList<>(); - List sized=attachment.stream().filter(a->a instanceof SizedAttachment).map(a->(SizedAttachment)a).collect(Collectors.toList()); + List sized=attachment.stream().filter(a->a instanceof SizedAttachment).map(a->(SizedAttachment)a).limit(10).collect(Collectors.toList()); if(!sized.isEmpty()){ float aspect; TiledLayoutResult tiledLayout; if(sized.size()==1){ SizedAttachment sa=sized.get(0); - aspect=sa.isSizeKnown() ? (sa.getWidth()/(float)sa.getHeight()) : 1f; + aspect=sa.isSizeKnown() ? Math.max(0.5f, Math.min(2f, (sa.getWidth()/(float)sa.getHeight()))) : 1f; tiledLayout=null; }else{ tiledLayout=processThumbs(510, 510, sized); @@ -104,7 +108,7 @@ public Object execute(Map args, PebbleTemplate self, EvaluationC } lines.add("
"); if(obj instanceof PhotoAttachment photo){ - renderPhotoAttachment(photo, lines, Math.max(tile.width, tile.height)); + renderPhotoAttachment(photo, lines, Math.round(Math.max(tile.width*510, tile.height*510))); } lines.add("
"); i++; @@ -167,7 +171,7 @@ private static TiledLayoutResult processThumbs(int _maxW, int _maxH, List ratios=new ArrayList<>(); int cnt=thumbs.size(); @@ -175,129 +179,153 @@ private static TiledLayoutResult processThumbs(int _maxW, int _maxH, List1.2 ? 'w' : (ratio<0.8 ? 'n' : 'q'); - orients+=orient; + if(ratio<=1.2f){ + allAreWide=false; + if(ratio<0.8f) + allAreSquare=false; + }else{ + allAreSquare=false; + } ratios.add(ratio); } float avgRatio=!ratios.isEmpty() ? sum(ratios)/ratios.size() : 1.0f; - float maxW, maxH, marginW=2, marginH=2; - if(_maxW>0){ - maxW=_maxW; - maxH=_maxH; - }else{ - maxW=510; - maxH=510; - } - - float maxRatio=maxW/maxH; + float maxRatio=(float) MAX_WIDTH/MAX_HEIGHT; if(cnt==2){ - if(orients.equals("ww") && avgRatio>1.4*maxRatio && (ratios.get(1)-ratios.get(0))<0.2){ // two wide photos, one above the other - float h=Math.min(maxW/ratios.get(0), Math.min(maxW/ratios.get(1), (maxH-marginH)/2.0f)); + if(allAreWide && avgRatio>1.4*maxRatio && Math.abs(ratios.get(1)-ratios.get(0))<0.2){ // two wide photos, one above the other + float h=Math.max(Math.min(MAX_WIDTH/ratios.get(0), Math.min(MAX_WIDTH/ratios.get(1), (MAX_HEIGHT-GAP)/2.0f)), MIN_HEIGHT/2f); - result.columnSizes=new int[]{1}; - result.rowSizes=new int[]{1, 1}; - result.width=Math.round(maxW); - result.height=Math.round(h*2+marginH); + result.width=MAX_WIDTH; + result.height=Math.round(h*2+GAP); + result.columnSizes=new int[]{result.width}; + result.rowSizes=new int[]{Math.round(h), Math.round(h)}; + result.tiles=new TiledLayoutResult.Tile[]{ + new TiledLayoutResult.Tile(1, 1, 1f, h/result.height), + new TiledLayoutResult.Tile(1, 1, 1f, h/result.height) + }; + }else if(allAreWide){ // two wide photos, one above the other, different ratios + result.width=MAX_WIDTH; + float h0=MAX_WIDTH/ratios.get(0); + float h1=MAX_WIDTH/ratios.get(1); + if(h0+h1 1.2 * maxRatio || avgRatio > 1.5 * maxRatio) &&*/ orients.equals("www")){ // 2nd and 3rd photos are on the next line - float hCover=Math.min(maxW/ratios.get(0), (maxH-marginH)*0.66f); - float w2=((maxW-marginW)/2); - float h=Math.min(maxH-hCover-marginH, Math.min(w2/ratios.get(1), w2/ratios.get(2))); - result.width=Math.round(maxW); - result.height=Math.round(hCover+h+marginH); - result.columnSizes=new int[]{1, 1}; + if((ratios.get(0) > 1.2 * maxRatio || avgRatio > 1.5 * maxRatio) || allAreWide){ // 2nd and 3rd photos are on the next line + float hCover=Math.min(MAX_WIDTH/ratios.get(0), (MAX_HEIGHT-GAP)*0.66f); + float w2=((MAX_WIDTH-GAP)/2); + float h=Math.min(MAX_HEIGHT-hCover-GAP, Math.min(w2/ratios.get(1), w2/ratios.get(2))); + if(hCover+h 1.2 * maxRatio || avgRatio > 1.5 * maxRatio) &&*/ orients.equals("wwww")){ // 2nd, 3rd and 4th photos are on the next line - float hCover=Math.min(maxW/ratios.get(0), (maxH-marginH)*0.66f); - float h=(maxW-2*marginW)/(ratios.get(1)+ratios.get(2)+ratios.get(3)); + if((ratios.get(0) > 1.2 * maxRatio || avgRatio > 1.5 * maxRatio) || allAreWide){ // 2nd, 3rd and 4th photos are on the next line + float hCover=Math.min(MAX_WIDTH/ratios.get(0), (MAX_HEIGHT-GAP)*0.66f); + float h=(MAX_WIDTH-2*GAP)/(ratios.get(1)+ratios.get(2)+ratios.get(3)); float w0=h*ratios.get(1); float w1=h*ratios.get(2); float w2=h*ratios.get(3); - h=Math.min(maxH-hCover-marginH, h); - result.width=Math.round(maxW); - result.height=Math.round(hCover+h+marginH); - result.columnSizes=new int[]{Math.round(w0), Math.round(w1), Math.round(w2)}; + h=Math.min(MAX_HEIGHT-hCover-GAP, h); + if(hCover+h ratiosCropped=new ArrayList(); + ArrayList ratiosCropped=new ArrayList<>(); if(avgRatio>1.1){ for(float ratio : ratios){ ratiosCropped.add(Math.max(1.0f, ratio)); @@ -311,14 +339,14 @@ private static TiledLayoutResult processThumbs(int _maxW, int _maxH, List tries=new HashMap<>(); // One line - int firstLine, secondLine, thirdLine; - tries.put(new int[]{firstLine=cnt}, new float[]{calculateMultiThumbsHeight(ratiosCropped, maxW, marginW)}); + int firstLine, secondLine; + tries.put(new int[]{cnt}, new float[]{calculateMultiThumbsHeight(ratiosCropped, MAX_WIDTH, GAP)}); // Two lines for(firstLine=1; firstLine<=cnt-1; firstLine++){ - tries.put(new int[]{firstLine, secondLine=cnt-firstLine}, new float[]{ - calculateMultiThumbsHeight(ratiosCropped.subList(0, firstLine), maxW, marginW), - calculateMultiThumbsHeight(ratiosCropped.subList(firstLine, ratiosCropped.size()), maxW, marginW) + tries.put(new int[]{firstLine, cnt-firstLine}, new float[]{ + calculateMultiThumbsHeight(ratiosCropped.subList(0, firstLine), MAX_WIDTH, GAP), + calculateMultiThumbsHeight(ratiosCropped.subList(firstLine, ratiosCropped.size()), MAX_WIDTH, GAP) } ); } @@ -326,26 +354,27 @@ private static TiledLayoutResult processThumbs(int _maxW, int _maxH, List1){ if(conf[0]>conf[1] || conf.length>2 && conf[1]>conf[2]){ - confDiff*=1.1; + confDiff*=1.1f; } } if(optConf==null || confDiff lineThumbs=new ArrayList<>(); - for(int j=0; j row=new ArrayList<>(); for(int j=0; j0; i--){ @@ -405,14 +434,18 @@ private static TiledLayoutResult processThumbs(int _maxW, int _maxH, List getFunctions(){ f.put("addQueryParams", new AddQueryParamsFunction()); f.put("randomString", new RandomStringFunction()); f.put("profileURL", new ProfileUrlFunction()); + f.put("profileRel", new ProfileRelFunction()); return f; } diff --git a/src/main/java/smithereen/templates/Templates.java b/src/main/java/smithereen/templates/Templates.java index a206d78a..9928105e 100644 --- a/src/main/java/smithereen/templates/Templates.java +++ b/src/main/java/smithereen/templates/Templates.java @@ -116,7 +116,7 @@ public static void addGlobalParamsToTemplate(Request req, RenderedTemplateRespon throw new InternalServerErrorException(x); } - if(info.permissions.serverAccessLevel.ordinal()>=Account.AccessLevel.MODERATOR.ordinal()){ + if(info.permissions.role!=null){ // TODO check if this role actually grants permissions that have counters in left menu model.with("serverSignupMode", Config.signupMode); model.with("adminNotifications", AdminNotifications.getInstance(req)); } @@ -133,7 +133,7 @@ public static void addGlobalParamsToTemplate(Request req, RenderedTemplateRespon jsLang.add("\""+key+"\":"+lang.getAsJS(key)); } } - for(String key: List.of("error", "ok", "network_error", "close", "cancel")){ + for(String key: List.of("error", "ok", "network_error", "close", "cancel", "yes", "no")){ jsLang.add("\""+key+"\":"+lang.getAsJS(key)); } if(req.attribute("mobile")!=null){ @@ -183,7 +183,7 @@ else if(req.attribute("mobile")!=null) public static void addJsLangForNewPostForm(Request req){ Utils.jsLangKey(req, - "post_form_cw", "post_form_cw_placeholder", "attach_menu_photo", "attach_menu_cw", "attach_menu_poll", "max_file_size_exceeded", "max_attachment_count_exceeded", "remove_attachment", + "post_form_cw", "post_form_cw_placeholder", "attach_menu_photo", "attach_menu_cw", "attach_menu_poll", "err_file_upload_too_large", "file_size_kilobytes", "file_size_megabytes", "max_attachment_count_exceeded", "remove_attachment", // polls "create_poll_question", "create_poll_options", "create_poll_add_option", "create_poll_delete_option", "create_poll_multi_choice", "create_poll_anonymous", "create_poll_time_limit", "X_days", "X_hours", // graffiti diff --git a/src/main/java/smithereen/util/DisallowLocalhostInterceptor.java b/src/main/java/smithereen/util/DisallowLocalhostInterceptor.java deleted file mode 100644 index 3b6dde24..00000000 --- a/src/main/java/smithereen/util/DisallowLocalhostInterceptor.java +++ /dev/null @@ -1,18 +0,0 @@ -package smithereen.util; - -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; - -import okhttp3.Interceptor; -import okhttp3.Response; -import smithereen.Config; - -public class DisallowLocalhostInterceptor implements Interceptor{ - @Override - public @NotNull Response intercept(@NotNull Chain chain) throws IOException{ - if(!Config.DEBUG && chain.connection().socket().getInetAddress().isLoopbackAddress()) - throw new IOException("Localhost connections are not allowed"); - return chain.proceed(chain.request()); - } -} diff --git a/src/main/java/smithereen/util/EmailCodeActionType.java b/src/main/java/smithereen/util/EmailCodeActionType.java new file mode 100644 index 00000000..8c613210 --- /dev/null +++ b/src/main/java/smithereen/util/EmailCodeActionType.java @@ -0,0 +1,11 @@ +package smithereen.util; + +public enum EmailCodeActionType{ + ACCOUNT_UNFREEZE; + + public String actionLangKey(){ + return switch(this){ + case ACCOUNT_UNFREEZE -> "email_confirm_action_unfreeze"; + }; + } +} diff --git a/src/main/java/smithereen/util/FloodControl.java b/src/main/java/smithereen/util/FloodControl.java index 548cbc2d..ab01e783 100644 --- a/src/main/java/smithereen/util/FloodControl.java +++ b/src/main/java/smithereen/util/FloodControl.java @@ -22,6 +22,7 @@ public class FloodControl{ public static final FloodControl EMAIL_RESEND=FloodControl.ofStringKey(1, 10, TimeUnit.MINUTES); public static final FloodControl EMAIL_INVITE=FloodControl.ofObjectKey(5, 1, TimeUnit.HOURS, acc->"account"+acc.id); public static final FloodControl OPEN_SIGNUP_OR_INVITE_REQUEST=FloodControl.ofIPKey(25, 5, TimeUnit.MINUTES); + public static final FloodControl ACTION_CONFIRMATION=FloodControl.ofObjectKey(5, 10, TimeUnit.MINUTES, acc->"account"+acc.id); private long timeout; private int count; diff --git a/src/main/java/smithereen/util/InetAddressRange.java b/src/main/java/smithereen/util/InetAddressRange.java new file mode 100644 index 00000000..ba881899 --- /dev/null +++ b/src/main/java/smithereen/util/InetAddressRange.java @@ -0,0 +1,134 @@ +package smithereen.util; + +import org.jetbrains.annotations.NotNull; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import smithereen.Utils; + +public record InetAddressRange(InetAddress address, int prefixLength){ + private static final Pattern IP_WITH_SUBNET_PATTERN=Pattern.compile("^(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|(?:[\\da-f]{0,4}:){1,7}[\\da-f]{0,4})(?:/(\\d{1,3}))?$", Pattern.CASE_INSENSITIVE); + + public byte[] getRawPrefix(boolean wholeBytesOnly){ + byte[] serialized=Utils.serializeInetAddress(address); + if(isSingleAddress()) + return serialized; + int prefixLengthInBytes=prefixLength >> 3; + if(!wholeBytesOnly && prefixLength%8!=0){ + prefixLengthInBytes++; + } + if(address instanceof Inet4Address){ + prefixLengthInBytes+=12; + } + byte[] prefix=new byte[prefixLengthInBytes]; + System.arraycopy(serialized, 0, prefix, 0, prefix.length); + return prefix; + } + + public InetAddress getMinAddress(){ + if(isSingleAddress()) + return address; + byte[] addr=address.getAddress(); + byte[] mask=getSubnetMask(); + for(int i=0;i> 3; + for(int i=0;i0){ + mask[prefixLengthInBytes]=(byte)(0xFF << (8-prefixLength%8)); + } + return mask; + } + + public boolean isSingleAddress(){ + return (address instanceof Inet4Address && prefixLength==32) || (address instanceof Inet6Address && prefixLength==128); + } + + public boolean contains(InetAddress addr){ + if((addr instanceof Inet4Address && address instanceof Inet6Address) || (addr instanceof Inet6Address && address instanceof Inet4Address)) + return false; + if(isSingleAddress()) + return addr.equals(address); + byte[] mask=getSubnetMask(); + byte[] raw=addr.getAddress(); + byte[] rawMasked=new byte[mask.length]; + for(int i=0;i Math.min(prefix, 32); + case Inet6Address ipv6 -> Math.min(prefix, 128); + default -> throw new IllegalStateException("Unexpected value: " + addr); + }; + return new InetAddressRange(addr, prefix); + }catch(UnknownHostException x){ + return null; + } + } +} diff --git a/src/main/java/smithereen/util/LoggingInterceptor.java b/src/main/java/smithereen/util/LoggingInterceptor.java deleted file mode 100644 index c06cdd20..00000000 --- a/src/main/java/smithereen/util/LoggingInterceptor.java +++ /dev/null @@ -1,27 +0,0 @@ -package smithereen.util; - -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.Objects; - -import okhttp3.Interceptor; -import okhttp3.Request; -import okhttp3.Response; - -public class LoggingInterceptor implements Interceptor{ - private static final Logger LOG=LoggerFactory.getLogger(LoggingInterceptor.class); - - @Override - public @NotNull Response intercept(Chain chain) throws IOException{ - Request req=chain.request(); - LOG.info("{} {} {}", req.method(), req.url().encodedPath()+Objects.requireNonNullElse(req.url().encodedQuery(), ""), chain.connection().protocol().toString().toUpperCase()); - LOG.info("{}", req.headers()); - Response resp=chain.proceed(req); - LOG.info("{} {} {}", resp.protocol().toString().toUpperCase(), resp.code(), resp.message()); - LOG.info("{}", resp.headers()); - return resp; - } -} diff --git a/src/main/java/smithereen/util/TopLevelDomainList.java b/src/main/java/smithereen/util/TopLevelDomainList.java index 78ba30cd..79e908da 100644 --- a/src/main/java/smithereen/util/TopLevelDomainList.java +++ b/src/main/java/smithereen/util/TopLevelDomainList.java @@ -4,17 +4,16 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.sql.SQLException; import java.util.Arrays; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import okhttp3.Call; -import okhttp3.Request; -import okhttp3.Response; import smithereen.Config; import smithereen.Utils; import smithereen.activitypub.ActivityPub; @@ -27,24 +26,18 @@ public class TopLevelDomainList{ public static void updateIfNeeded(){ if(System.currentTimeMillis()-lastUpdatedTime>3600_000L*24*14){ // update once every 14 days - BackgroundTaskRunner.getInstance().submit(new Runnable(){ - @Override - public void run(){ - try{ - Request req=new Request.Builder() - .url("https://data.iana.org/TLD/tlds-alpha-by-domain.txt") - .build(); - Call call=ActivityPub.httpClient.newCall(req); - try(Response resp=call.execute()){ - if(resp.isSuccessful()){ - String file=resp.body().string(); - update(file); - Config.updateInDatabase(Map.of("TLDList_LastUpdated", (lastUpdatedTime=System.currentTimeMillis())+"", "TLDList_Data", file)); - } - } - }catch(IOException|SQLException x){ - LOG.warn("Error loading IANA TLD list", x); + BackgroundTaskRunner.getInstance().submit(()->{ + try{ + HttpRequest req=HttpRequest.newBuilder(URI.create("https://data.iana.org/TLD/tlds-alpha-by-domain.txt")) + .build(); + HttpResponse resp=ActivityPub.httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + if(resp.statusCode()/100==2){ + String file=resp.body(); + update(file); + Config.updateInDatabase(Map.of("TLDList_LastUpdated", (lastUpdatedTime=System.currentTimeMillis())+"", "TLDList_Data", file)); } + }catch(IOException|SQLException|InterruptedException x){ + LOG.warn("Error loading IANA TLD list", x); } }); } diff --git a/src/main/java/smithereen/util/TranslatableEnum.java b/src/main/java/smithereen/util/TranslatableEnum.java new file mode 100644 index 00000000..87f7f5cb --- /dev/null +++ b/src/main/java/smithereen/util/TranslatableEnum.java @@ -0,0 +1,5 @@ +package smithereen.util; + +public interface TranslatableEnum>{ + String getLangKey(); +} diff --git a/src/main/java/smithereen/model/UriBuilder.java b/src/main/java/smithereen/util/UriBuilder.java similarity index 93% rename from src/main/java/smithereen/model/UriBuilder.java rename to src/main/java/smithereen/util/UriBuilder.java index f7c3b3d9..27e740b9 100644 --- a/src/main/java/smithereen/model/UriBuilder.java +++ b/src/main/java/smithereen/util/UriBuilder.java @@ -1,6 +1,7 @@ -package smithereen.model; +package smithereen.util; import java.net.URI; +import java.net.URISyntaxException; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -171,13 +172,23 @@ public static Map parseQueryString(String query){ return Arrays.stream(query.split("&")).map(s->{ int offset=s.indexOf('='); if(offset==-1) - return new KeyValuePair(s, null); + return new KeyValuePair(s, ""); return new KeyValuePair(s.substring(0, offset), urlDecode(s.substring(offset+1))); }).collect(Collectors.toMap(kv->kv.key, kv->kv.value)); } return Collections.emptyMap(); } + public static URI parseAndEncode(String str) throws URISyntaxException{ + URI uri=new URI(str); + for(char c:str.toCharArray()){ + if(c>128){ + return URI.create(uri.toASCIIString()); + } + } + return uri; + } + private static String urlEncode(String in){ if(in==null) return null; diff --git a/src/main/java/smithereen/util/UserAgentInterceptor.java b/src/main/java/smithereen/util/UserAgentInterceptor.java deleted file mode 100644 index b902c15a..00000000 --- a/src/main/java/smithereen/util/UserAgentInterceptor.java +++ /dev/null @@ -1,22 +0,0 @@ -package smithereen.util; - -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; - -import okhttp3.Interceptor; -import okhttp3.Request; -import okhttp3.Response; -import smithereen.BuildInfo; -import smithereen.Config; - -public class UserAgentInterceptor implements Interceptor{ - @Override - public @NotNull Response intercept(Chain chain) throws IOException{ - Request req=chain.request(); - req=req.newBuilder() - .header("User-Agent", "Smithereen/"+BuildInfo.VERSION+" ("+Config.domain+") "+req.header("User-Agent")) - .build(); - return chain.proceed(req); - } -} diff --git a/src/main/java/smithereen/util/XmlParser.java b/src/main/java/smithereen/util/XmlParser.java new file mode 100644 index 00000000..2c878668 --- /dev/null +++ b/src/main/java/smithereen/util/XmlParser.java @@ -0,0 +1,81 @@ +package smithereen.util; + +import org.jetbrains.annotations.NotNull; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.StringWriter; +import java.util.Iterator; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +public class XmlParser{ + private static final DocumentBuilderFactory factory=DocumentBuilderFactory.newInstance(); + + static{ + try{ + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + }catch(ParserConfigurationException e){ + throw new RuntimeException(e); + } + factory.setXIncludeAware(false); + } + + public static DocumentBuilder newDocumentBuilder(){ + try{ + return factory.newDocumentBuilder(); + }catch(ParserConfigurationException e){ + throw new RuntimeException(e); + } + } + + public static String serialize(Document doc){ + try{ + Transformer transformer=TransformerFactory.newDefaultInstance().newTransformer(); + StringWriter writer=new StringWriter(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + return writer.toString(); + }catch(TransformerException x){ + throw new RuntimeException(x); + } + } + + public static Iterable iterateNodes(NodeList list){ + return new NodeListIterable(list); + } + + private record NodeListIterable(NodeList list) implements Iterable{ + @NotNull + @Override + public Iterator iterator(){ + return new NodeListIterator(list); + } + } + + private static final class NodeListIterator implements Iterator{ + private int current=0; + private final NodeList list; + + private NodeListIterator(NodeList list){ + this.list=list; + } + + @Override + public boolean hasNext(){ + return current RULES=List.of( + new BrowserTypeRule(BrowserType.GOOGLEBOT, "googlebot"), + new BrowserTypeRule(BrowserType.OPERA_PRESTO, "opera"), + new BrowserTypeRule(BrowserType.OPERA_CHROMIUM, "opr\\/|opios"), + new BrowserTypeRule(BrowserType.SAMSUNG_BROWSER, "SamsungBrowser"), + new BrowserTypeRule(BrowserType.WHALE, "Whale"), + new BrowserTypeRule(BrowserType.PALE_MOON, "PaleMoon"), + new BrowserTypeRule(BrowserType.MZ_BROWSER, "MZBrowser"), + new BrowserTypeRule(BrowserType.FOCUS, "focus"), + new BrowserTypeRule(BrowserType.SWING, "swing"), + new BrowserTypeRule(BrowserType.OPERA_COAST, "coast"), + new BrowserTypeRule(BrowserType.OPERA_TOUCH, "opt\\/\\d+(?:.?_?\\d+)+"), + new BrowserTypeRule(BrowserType.YANDEX_BROWSER, "yabrowser"), + new BrowserTypeRule(BrowserType.UC_BROWSER, "ucbrowser"), + new BrowserTypeRule(BrowserType.MAXTHON, "Maxthon|mxios"), + new BrowserTypeRule(BrowserType.EPIPHANY, "epiphany"), + new BrowserTypeRule(BrowserType.PUFFIN, "puffin"), + new BrowserTypeRule(BrowserType.SLEIPNIR, "sleipnir"), + new BrowserTypeRule(BrowserType.K_MELEON, "k-meleon"), + new BrowserTypeRule(BrowserType.WECHAT, "micromessenger"), + new BrowserTypeRule(BrowserType.QQ_BROWSER, "qqbrowser"), + new BrowserTypeRule(BrowserType.INTERNET_EXPLORER, "msie|trident"), + new BrowserTypeRule(BrowserType.MS_EDGE, "\\sedg\\/"), + new BrowserTypeRule(BrowserType.MS_EDGE_IOS, "edg([ea]|ios)"), + new BrowserTypeRule(BrowserType.VIVALDI, "vivaldi"), + new BrowserTypeRule(BrowserType.SEAMONKEY, "seamonkey"), + new BrowserTypeRule(BrowserType.SAILFISH, "sailfish"), + new BrowserTypeRule(BrowserType.AMAZON_SILK, "silk"), + new BrowserTypeRule(BrowserType.PHANTOMJS, "phantom"), + new BrowserTypeRule(BrowserType.SLIMERJS, "slimerjs"), + new BrowserTypeRule(BrowserType.BLACKBERRY, "blackberry|\\bbb\\d+", "rim\\stablet"), + new BrowserTypeRule(BrowserType.WEBOS_BROWSER, "(web|hpw)[o0]s"), + new BrowserTypeRule(BrowserType.BADA, "bada"), + new BrowserTypeRule(BrowserType.TIZEN, "tizen"), + new BrowserTypeRule(BrowserType.QUPZILLA, "qupzilla"), + new BrowserTypeRule(BrowserType.FIREFOX, "firefox|iceweasel|fxios"), + new BrowserTypeRule(BrowserType.ELECTRON, "electron"), + new BrowserTypeRule(BrowserType.MIUI_BROWSER, "MiuiBrowser"), + new BrowserTypeRule(BrowserType.CHROMIUM, "chromium"), + new BrowserTypeRule(BrowserType.CHROME, "chrome|crios|crmo"), + new BrowserTypeRule(BrowserType.GOOGLE_SEARCH, "GSA"), + new BrowserTypeRule(BrowserType.ANDROID_BROWSER, "(? OS_RULES=List.of( + new OSRule(BrowserOSFamily.ROKU, Pattern.compile("Roku/DVP")), + new OSRule(BrowserOSFamily.WINDOWS_PHONE, "windows phone"), + new OSRule(BrowserOSFamily.WINDOWS, "windows "), + new OSRule(BrowserOSFamily.IOS, Pattern.compile("Macintosh(.*?) FxiOS(.*?)/")), + new OSRule(BrowserOSFamily.MAC_OS, "macintosh"), + new OSRule(BrowserOSFamily.IOS, "(ipod|iphone|ipad)"), + new OSRule(BrowserOSFamily.ANDROID, "(? PLATFORM_RULES=List.of( + new PlatformTypeRule(BrowserPlatformType.BOT, Pattern.compile("googlebot", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.MOBILE, Pattern.compile("huawei", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.TABLET, Pattern.compile("nexus\\s*(?:7|8|9|10).*", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.TABLET, Pattern.compile("ipad", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.TABLET, Pattern.compile("Macintosh(.*?) FxiOS(.*?)/")), + new PlatformTypeRule(BrowserPlatformType.TABLET, Pattern.compile("kftt build", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.TABLET, Pattern.compile("silk", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.TABLET, Pattern.compile("tablet(?! pc)", Pattern.CASE_INSENSITIVE)), + new PlatformTypeRule(BrowserPlatformType.MOBILE, Pattern.compile("(? regexes){ + public BrowserTypeRule(BrowserType type, @RegExp String regex){ + this(type, List.of(Pattern.compile(regex, Pattern.CASE_INSENSITIVE))); + } + + public BrowserTypeRule(BrowserType type, @RegExp String... regexes){ + this(type, Arrays.stream(regexes).map(r->Pattern.compile(r, Pattern.CASE_INSENSITIVE)).toList()); + } + } + + private record OSRule(BrowserOSFamily os, Pattern regex){ + public OSRule(BrowserOSFamily os, String regex){ + this(os, Pattern.compile(regex, Pattern.CASE_INSENSITIVE)); + } + } + + private record PlatformTypeRule(BrowserPlatformType type, BiPredicate predicate){ + public PlatformTypeRule(BrowserPlatformType type, Pattern regex){ + this(type, (ua, os)->regex.matcher(ua).find()); + } + + public PlatformTypeRule(BrowserPlatformType type, BrowserOSFamily _os){ + this(type, (ua, os)->os==_os); + } + } + + private enum BrowserType{ + GOOGLEBOT, + OPERA_PRESTO, + OPERA_CHROMIUM, + SAMSUNG_BROWSER, + WHALE, + PALE_MOON, + MZ_BROWSER, + FOCUS, + SWING, + OPERA_COAST, + OPERA_TOUCH, + YANDEX_BROWSER, + UC_BROWSER, + MAXTHON, + EPIPHANY, + PUFFIN, + SLEIPNIR, + K_MELEON, + WECHAT, + QQ_BROWSER, + INTERNET_EXPLORER, + MS_EDGE, + MS_EDGE_IOS, + VIVALDI, + SEAMONKEY, + SAILFISH, + AMAZON_SILK, + PHANTOMJS, + SLIMERJS, + BLACKBERRY, + WEBOS_BROWSER, + BADA, + TIZEN, + QUPZILLA, + FIREFOX, + ELECTRON, + MIUI_BROWSER, + CHROMIUM, + CHROME, + GOOGLE_SEARCH, + ANDROID_BROWSER, + PLAYSTATION_4, + SAFARI, + OTHER; + + public String getName(String ua){ + return switch(this){ + case GOOGLEBOT -> "Googlebot"; + case OPERA_PRESTO, OPERA_CHROMIUM -> "Opera"; + case SAMSUNG_BROWSER -> "Samsung Internet"; + case WHALE -> "NAVER Whale"; + case PALE_MOON -> "Pale Moon"; + case MZ_BROWSER -> "MZ Browser"; + case FOCUS -> "Focus"; + case SWING -> "Swing"; + case OPERA_COAST -> "Opera Coast"; + case OPERA_TOUCH -> "Opera Touch"; + case YANDEX_BROWSER -> "Yandex Browser"; + case UC_BROWSER -> "UC Browser"; + case MAXTHON -> "Maxthon"; + case EPIPHANY -> "Epiphany"; + case PUFFIN -> "Puffin"; + case SLEIPNIR -> "Sleipnir"; + case K_MELEON -> "K-Meleon"; + case WECHAT -> "WeChat"; + case QQ_BROWSER -> ua.toLowerCase().contains("qqbrowserlite") ? "QQ Browser Lite" : "QQ Browser"; + case INTERNET_EXPLORER -> "Internet Explorer"; + case MS_EDGE, MS_EDGE_IOS -> "Microsoft Edge"; + case VIVALDI -> "Vivaldi"; + case SEAMONKEY -> "SeaMonkey"; + case SAILFISH -> "Sailfish"; + case AMAZON_SILK -> "Amazon Silk"; + case PHANTOMJS -> "PhantomJS"; + case SLIMERJS -> "SlimerJS"; + case BLACKBERRY -> "BlackBerry"; + case WEBOS_BROWSER -> "WebOS Browser"; + case BADA -> "Bada"; + case TIZEN -> "Tizen"; + case QUPZILLA -> "QupZilla"; + case FIREFOX -> "Firefox"; + case ELECTRON -> "Electron"; + case MIUI_BROWSER -> "MIUI Browser"; + case CHROMIUM -> "Chromium"; + case CHROME -> "Chrome"; + case GOOGLE_SEARCH -> "Google Search"; + case ANDROID_BROWSER -> "Android Browser"; + case PLAYSTATION_4 -> "PlayStation 4"; + case SAFARI -> "Safari"; + case OTHER -> { + Matcher matcher=(ua.contains("(") ? REGEX_WITH_DEVICE_SPEC : REGEX_WITHOUT_DEVICE_SPEC).matcher(ua); + yield matcher.find() ? matcher.group(1) : "Unknown Browser"; + } + }; + } + + public String getVersion(String ua){ + return switch(this){ + case GOOGLEBOT -> getFirstMatchWithFallback(ua, GOOGLEBOT_VERSION, COMMON_VERSION_IDENTIFIER); + case OPERA_PRESTO -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, OPERA_VERSION1); + case OPERA_CHROMIUM -> getFirstMatchWithFallback(ua, OPERA_VERSION2, COMMON_VERSION_IDENTIFIER); + case SAMSUNG_BROWSER -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, SAMSUNG_VERSION); + case WHALE -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, WHALE_VERSION); + case PALE_MOON -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, PALE_MOON_VERSION); + case MZ_BROWSER -> getFirstMatchWithFallback(ua, MZ_BROWSER_VERSION, COMMON_VERSION_IDENTIFIER); + case FOCUS -> getFirstMatchWithFallback(ua, FOCUS_VERSION, COMMON_VERSION_IDENTIFIER); + case SWING -> getFirstMatchWithFallback(ua, SWING_VERSION, COMMON_VERSION_IDENTIFIER); + case OPERA_COAST -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, OPERA_COAST_VERSION); + case OPERA_TOUCH -> getFirstMatchWithFallback(ua, OPERA_TOUCH_VERSION, COMMON_VERSION_IDENTIFIER); + case YANDEX_BROWSER -> getFirstMatchWithFallback(ua, YANDEX_VERSION, COMMON_VERSION_IDENTIFIER); + case UC_BROWSER -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, UC_VERSION); + case MAXTHON -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, MAXTHON_VERSION); + case EPIPHANY -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, EPIPHANY_VERSION); + case PUFFIN -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, PUFFIN_VERSION); + case SLEIPNIR -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, SLEIPNIR_VERSION); + case K_MELEON -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, K_MELEON_VERSION); + case WECHAT -> getFirstMatchWithFallback(ua, WECHAT_VERSION, COMMON_VERSION_IDENTIFIER); + case QQ_BROWSER -> getFirstMatchWithFallback(ua, QQ_VERSION, COMMON_VERSION_IDENTIFIER); + case INTERNET_EXPLORER -> getFirstMatch(ua, IE_VERSION); + case MS_EDGE -> getFirstMatch(ua, EDGE_VERSION); + case MS_EDGE_IOS -> getFirstMatch(ua, EDGE_IOS_VERSION); + case VIVALDI -> getFirstMatch(ua, VIVALDI_VERSION); + case SEAMONKEY -> getFirstMatch(ua, SEAMONKEY_VERSION); + case SAILFISH -> getFirstMatch(ua, SAILFISH_VERSION); + case AMAZON_SILK -> getFirstMatch(ua, SILK_VERSION); + case PHANTOMJS -> getFirstMatch(ua, PHANROMJS_VERSION); + case SLIMERJS -> getFirstMatch(ua, SLIMERJS_VERSION); + case BLACKBERRY -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, BLACKBERRY_VERSION); + case WEBOS_BROWSER -> getFirstMatchWithFallback(ua, COMMON_VERSION_IDENTIFIER, WEBOS_VERSION); + case BADA -> getFirstMatch(ua, BADA_VERSION); + case TIZEN -> getFirstMatchWithFallback(ua, TIZEN_VERSION, COMMON_VERSION_IDENTIFIER); + case QUPZILLA -> getFirstMatchWithFallback(ua, QUPZILLA_VERSION, COMMON_VERSION_IDENTIFIER); + case FIREFOX -> getFirstMatch(ua, FIREFOX_VERSION); + case ELECTRON -> getFirstMatch(ua, ELECTRON_VERSION); + case MIUI_BROWSER -> getFirstMatch(ua, MIUI_VERSION); + case CHROMIUM -> getFirstMatchWithFallback(ua, CHROMIUM_VERSION, COMMON_VERSION_IDENTIFIER); + case CHROME -> getFirstMatch(ua, CHROME_VERSION); + case GOOGLE_SEARCH -> getFirstMatch(ua, GOOGLE_VERSION); + case ANDROID_BROWSER, PLAYSTATION_4, SAFARI -> getFirstMatch(ua, COMMON_VERSION_IDENTIFIER); + case OTHER -> { + Matcher matcher=(ua.contains("(") ? REGEX_WITH_DEVICE_SPEC : REGEX_WITHOUT_DEVICE_SPEC).matcher(ua); + yield matcher.find() ? matcher.group(2) : null; + } + }; + } + } +} diff --git a/src/main/resources/langs/de/admin.json b/src/main/resources/langs/de/admin.json index 33df0392..ffbedfce 100644 --- a/src/main/resources/langs/de/admin.json +++ b/src/main/resources/langs/de/admin.json @@ -1,28 +1,25 @@ { - "menu_admin": "Admin Bereich", - "admin_server_info": "Server Info", + "menu_admin": "Verwalter-Bereich", + "admin_server_info": "Serverinformation", "admin_server_name": "Name", "admin_server_description": "Beschreibung", - "admin_server_admin_email": "Admin E-Mail", - "admin_server_info_updated": "Server Info aktualisiert", + "admin_server_admin_email": "Verwalter E-Mail", + "admin_server_info_updated": "Serverinformation aktualisiert", "admin_users": "Benutzer", - "admin_user_id": "Account ID (user ID)", + "admin_user_id": "Benutzer-ID (User ID)", "invited_by": "Eingeladen durch", - "signup_date": "Anmeldedatum", + "signup_date": "Registrierungsdatum", "actions": "Aktionen", - "access_level": "Berechtigungsstufe", - "choose_access_level_for_X": "Wähle eine Berechtigungsstufe für {name}", - "access_level_regular": "Regulärer Benutzer", - "access_level_moderator": "Moderator", - "access_level_admin": "Administrator", - "admin_signup_mode": "Anmeldemodus", + "role": "Rolle", + "choose_role_for_X": "Wähle Rolle für {name}", + "admin_signup_mode": "Modus der Registrierung", "admin_signup_mode_open": "Offen", "admin_signup_mode_invite": "Nur auf Einladung", "admin_signup_mode_approval": "Durch Bewerbung (mit manueller Freigabe durch einen Administrator)", - "admin_signup_mode_closed": "Anmeldungen geschlossen", + "admin_signup_mode_closed": "Registrierungen sind geschlossen", "admin_signup_mode_explain": "Wenn Registrierungen geschlossen sind, haben Administratoren immer noch die Möglichkeit neue Benutzer einzuladen.", "admin_other": "Sonstiges", - "admin_email_settings": "Mail Einstellungen", + "admin_email_settings": "E-Mail-Einstellungen", "admin_email_from": "Absenderadresse", "admin_email_smtp_server": "SMTP Server", "admin_email_smtp_port": "Port", @@ -34,9 +31,248 @@ "admin_server_short_description": "Kurzbeschreibung", "admin_server_policy": "Richtlinien", "admin_server_info_html_explain": "Du kannst HTML-Formatierungen in der Beschreibung und im Richtlinien-Feld verwenden.", - "admin_ban": "Verbannen", - "admin_unban": "Entbannen", - "admin_ban_X_confirm": "Bist du sicher, dass du das Konto {name} verbannen möchtest?", - "admin_unban_X_confirm": "Bist du sicher, dass du das Konto {name} entbannen möchtest?", - "ban_reason": "Grund für die Verbannung" + "sync_friends_and_groups": "Synchronisiere Freunde & groups", + "sync_members": "Synchronisiere Mitgliederliste", + "sync_content": "Synchronisiere Inhalt", + "sync_profile": "Synchronisiere Profil", + "sync_started": "Synchronisation wurde im Hintergrundprozess gestartet.", + "admin_require_email_confirm": "Benötige bestätigte Email für Kontoerstellung", + "admin_activate_account": "Aktiviere", + "admin_activate_X_confirm": "Bist Du sicher, dass Du das Konto {name} aktivieren willst?", + "menu_signup_requests": "Kontoerstellungsanfragen", + "summary_X_signup_requests": "{count, plural, =0 {Keine Anfragen} one {# Anfrage} other {# Anfragen}} zu/r Kontoerstellung/en", + "no_signup_requests": "Es gibt keine ungesehenen Kontoerstellungs-Anfragen.", + "signup_requests_title": "Kontoerstellungsanfragen", + "signup_request_sent_at": "Gesendet", + "delete_signup_request": "Lösche Anfrage", + "signup_request_deleted": "Anfrage gelöscht", + "menu_reports": "Auswertungen", + "reports_tab_open": "Ungelöst", + "reports_tab_resolved": "Gelöst", + "summary_X_reports": "{count, plural, one {# Auswertung} other {# Auswertungen}} Summe", + "no_reports": "Es gibt keine Auswertungen", + "report_from": "Von", + "report_sender_anonymous": "Anonym", + "report_sent_at": "Gesendet", + "report_comment": "Zusätzliche Benutzerinformation", + "private_post_warning_title": "Du hast keine Berechtigung zum Zugriff auf diese Nachricht", + "private_group_post_warning": "Diese Nachricht ist in einer {groupType, select, private {privaten} other {geschlossenen}} Gruppe, in der Du kein Mitglied bist. Du kannst Sie nur sehen, da Du die Nachricht von einer Auswertung aus geöffnet hast.", + "report_action_add_cw": "Füge CW hinzu", + "admin_federation": "Verbindung", + "search_server_domain": "Serverdomäne", + "summary_X_servers": "{count, plural, one {# Server} other {# Server}} Summe", + "summary_X_servers_found": "{count, plural, one {# Server} other {# Server}} gefunden", + "server_state_not_restricted": "Nicht eingeschränkt", + "server_state_suspended": "Angehalten", + "server_filter_all": "Alle", + "server_filter_restricted": "Eingeschränkt", + "server_filter_any_availability": "Jegliche Verfügbarkeit", + "server_filter_failing": "Fehlerhaft", + "server_filter_unavailable": "Nicht verfügbar", + "no_servers": "Es gibt keine Server anzuzeigen", + "server_restrictions": "Einschränkungen", + "server_restrictions_none": "Dieser Server ist uneingeschränkt.", + "server_restrictions_suspended": "Der Verbund mit diesem Server ist angehalten. Seine Benutzer können weder uni- noch bidirektional mit ihm interagieren.", + "server_restrictions_change": "Bearbeite Beschränkungen...", + "server_availability": "Verfügbarkeit", + "server_availability_explain": "Wenn die Aktivitätenzustellung zu diesem Server an 7 verschiedenen Tagen fehlschlägt, wird der Server als unerreichbar markiert und die Verbindung wird pausiert.\nDie Verbindung mit diesem Server wird automatisch wieder aufgenommen, beim Erhalt irgendeiner Aktivität von diesem Server.", + "server_availability_up": "Der Server ist verfügbar.", + "server_availability_failing": "Die letzten Aktivitäten Lieferversuche zu diesem Server waren nicht erfolgreich. Wenn die Lieferung für {days, plural, =1 {einen Tag mehr} one {# Tag} other {# verschiedene Tage}} fehlschläft, wird dieser Server als unerreichbar markiert. Der letzte Fehler passierte am {lastErrorDate}.", + "server_availability_down": "Der Server ist nicht verfügbar. Verbindung wurde pausiert.", + "server_reset_availability": "Markiere Server als verfügbar und nehme die Verbindung wieder auf", + "federation_restriction_public_comment": "Öffentlicher Kommentar", + "federation_restriction_public_comment_explain": "Wird für jeden auf der \"Über diesen Server\" Seite sichtbar sein.", + "federation_restriction_private_comment": "Privater Kommentar", + "federation_restriction_private_comment_explain": "Wird nur in der Zukunft für Dich selbst und andere Moderatoren sichtbar sein.", + "federation_restriction_title": "Serverbeschränkungen", + "federation_no_restrictions": "Uneingeschränkt", + "federation_suspend": "Verbindung angehalten", + "federation_suspend_explain": "Die Benutzer dieses Servers werden weder uni- noch bidirektional mit Deinen Benutzern interagieren können.", + "about_server_federation_restrictions": "Verbindungsbeschränkungen", + "about_server_federation_restrictions_explain": "Die Administratoren dieses Servers haben die Verbindung eingeschränkt mit {count, plural, one {# anderem Server} other {# anderen Servern}}:", + "federation_restriction_reason": "Grund", + "post_deleted_placeholder": "Die Nachricht wurde gelöscht", + "report_resolved_at": "Gelöst", + "admin_enable_captcha": "Benutze Captcha", + "admin_captcha_signup_form": "Im Anmeldeformular", + "server_stats_activities_sent": "Aktivitäten gesendet", + "server_stats_activities_received": "Aktivitäten empfangen", + "server_stats_delivery_errors": "Zustellungsfehler", + "menu_users": "Benutzer", + "menu_access": "Verbindung & Zugriff", + "menu_stats": "Statistiken", + "admin_invites": "Einladungen", + "role_none": "(keine Rolle)", + "role_owner": "Eigentümer", + "role_admin": "Administrator", + "role_moderator": "Moderator", + "admin_server_settings": "Server", + "admin_roles": "Rollen", + "admin_roles_summary": "{count, plural, one {# Rolle} other {# Rollen}}", + "admin_create_role": "Erstelle eine Neue", + "admin_permission_superuser": "Administrator", + "admin_permission_server_settings": "Servereinstellungen verwalten", + "admin_permission_rules": "Serverregeln verwalten", + "admin_permission_roles": "Benutzerrollen verwalten", + "admin_permission_audit_log": "Überwachungsprotokoll anzeigen", + "admin_permission_manage_users": "Benutzer verwalten", + "admin_permission_user_access": "Benutzerzugriff verwalten", + "admin_permission_reports": "Berichte verwalten", + "admin_permission_federation": "Verbindungen verwalten", + "admin_permission_blocking_rules": "Sperr-Regeln verwalten", + "admin_permission_invites": "Einladungen verwalten", + "admin_permission_announcements": "Ankündigungen verwalten", + "admin_permission_delete_users": "Benutzerkonten und -daten löschen", + "admin_permission_manage_groups": "Verwalte Gruppen und Veranstaltungen", + "admin_visible_in_staff": "Angezeigt in Serververwalter", + "admin_permission_descr_superuser": "Hat alle Berechtigungen und kann alles tun", + "admin_permission_descr_server_settings": "Erlaubt den Servernamen, die Beschreibung, den Modus der Registrierung und andere Einstellungen zu ändern", + "admin_permission_descr_rules": "Erlaubt das editieren, hinzufügen und entfernen von Serverregeln", + "admin_permission_descr_roles": "Erlaubt das Editieren, das Erstellen und die Zuweisung von Benutzerrollen die gleich oder weniger gleich als die ihren sind", + "admin_permission_descr_audit_log": "Erlaubt das Sichten des Berichts von allen Verwaltungsaufgaben, die auf diesem Server ausgeführt werden", + "admin_permission_descr_manage_users": "Erlaubt das Sichten von Benutzerdetails und das ausführen von Moderationsaufgaben gegen ihre Konten", + "admin_permission_descr_user_access": "Erlaubt das Zurücksetzen der Benutzerpasswörter und ändern ihrer E-mails", + "admin_permission_descr_reports": "Erlaubt das Anzeigen und das Handeln an Berichten", + "admin_permission_descr_federation": "Erlaubt das Ändern von Verbindungsbeschränkungen an diesem Server", + "admin_permission_descr_blocking_rules": "Erlaubt das Blockieren von E-Mail-Anbietern und IP-Adressen", + "admin_permission_descr_invites": "Erlaubt das Sichten von allen aktiven Einladungsverknüpfungen auf dem Server und das Deaktivieren derselbigen", + "admin_permission_descr_announcements": "Erlaubt das Erstellen und Editieren von Ankündigungen auf diesem Server", + "admin_permission_descr_delete_users": "Erlaubt das sofortige Löschen von Benutzerkonten ohne 30 Tage zu warten", + "admin_permission_descr_manage_groups": "Erlaubt das Sichten von Details von Gruppen und das Ausführen von Moderationsaktionen gegen sie", + "admin_visible_in_staff_descr": "Zeigt Benutzer mit dieser Rolle unter \"Verwaltung\" in \"Über diesen Server\"", + "admin_delete_role": "Lösche Rolle", + "admin_delete_role_confirm": "Bist Du sicher, dass Du die Rolle aller Benutzer, die diese zugewiesen haben, entfernen willst? Diese Aktion kann nicht rückgängig gemacht werden.", + "admin_role_name": "Rollenname", + "admin_permissions": "Berechtigungen", + "admin_role_X_saved": "Rolle \"{name}\" wurde gesichert", + "admin_role_X_created": "Rolle \"{name}\" wurde erstellt", + "admin_no_permissions_selected": "Wähle mindestens eine Berechtigung", + "admin_edit_role_title": "Editiere Rolle", + "admin_create_role_title": "Erstelle Rolle", + "admin_audit_log": "Prüfungsbericht", + "admin_audit_log_summary": "{count, plural, =0 {Keine Berichtseinträge} one {# Berichtseintrag total} other {# Berichtseinträge total}}", + "admin_audit_log_empty": "Datensätze über Verwaltungsaktionen werden hier erscheinen", + "admin_audit_log_created_role": "{name} hat die Rolle \"{roleName}\" erstellt", + "admin_audit_log_edited_role": "{name} hat Rolle \"{roleName}\" bearbeitet", + "admin_audit_log_deleted_role": "{name} hat Rolle \"{roleName}\" gelöscht", + "admin_audit_log_assigned_role": "{name} hat Rolle \"{roleName}\" an {targetName} zugewiesen", + "admin_audit_log_unassigned_role": "{name} hat {targetName}''s Rolle entfernt", + "admin_user_location_any": "Jeder Standort", + "admin_user_location_local": "Lokaler Standort", + "admin_user_location_remote": "Entfernter Standort", + "summary_X_users": "{count, plural, one {# Benutzer} other {# Benutzer}} total", + "summary_X_users_found": "{count, plural, =0 {Keine Benutzer} one {# Benutzer} other {# Benutzer}} gefunden", + "admin_user_email_domain": "E-Mail Domäne", + "admin_user_ip_or_subnet": "IP-Adresse oder Subnetz", + "any_role": "(jede)", + "search_users": "Suche Benutzer", + "no_users_found": "Keine Benutzer gefunden, die diesen Kriterien entsprechen", + "admin_last_user_activity": "Zuletzt aktiv", + "admin_manage_user": "Bearbeite Benutzer", + "admin_user_staff_notes": "Teamnotizen", + "admin_account_id_tooltip": "Locale Konto-ID", + "admin_ap_actor_id_tooltip": "Entfernte ActivityPub Akteur ID", + "admin_account_activation_status": "Aktivierung", + "admin_account_activated": "E-mail bestätigt", + "admin_account_email_unconfirmed": "E-mail nicht bestätigt", + "admin_account_email_change_pending": "E-mail - Änderung nach {newEmail} wurde beantragt, neue E-mail nicht bestätigt", + "admin_others_with_this_domain": "Andere mit dieser Domäne", + "admin_actor_last_updated": "Zuletzt aktualisiert", + "admin_end_session": "Beende Sitzung", + "admin_audit_log_activated_account": "{name} aktivierte {targetName}''s Konto", + "admin_audit_log_changed_email": "{name} änderte {targetName}''s E-mail", + "admin_audit_log_reset_password": "{name} setzte {targetName}''s password zurück", + "admin_audit_log_ended_session": "{name} beendete eine von {targetName}''s Sitzungen", + "admin_session_terminated": "Sitzung beendet", + "admin_user_restrictions": "Beschränkungen", + "admin_user_change_restrictions": "Ändere Beschränkungen...", + "admin_ban_user_title": "Benutzerbeschränkungen", + "admin_user_no_restrictions": "Keine Beschränkungen", + "admin_user_freeze": "Sperren", + "admin_user_freeze_explain": "Blockiere temporär den Benutzerzugriff auf dessen Konto", + "admin_user_suspend": "Anhalten", + "admin_user_suspend_explain": "Blockiere Konto komplett, verstecke es vor jedem und lösche es in 30 Tagen", + "admin_user_foreign_suspend_explain": "Blockiere dieses Konto komplett für jegliche Interaktionen mit Benutzern und Inhalten auf Deinem Server", + "admin_user_hide": "Verstecken", + "admin_user_hide_explain": "Mache das Profil nur für angemeldete Benutzer sichtbar", + "admin_user_ban_message": "Nachricht", + "admin_user_ban_message_explain": "Wird dem Benutzer angezeigt", + "admin_user_ban_duration": "Dauer", + "admin_user_ban_until_first_login": "Bis zur ersten Anmeldung", + "admin_user_ban_force_password_change": "Erzwinge Passwortänderung", + "admin_user_state_no_restrictions": "Es wurden keine Beschränkungen übernommen.", + "admin_user_state_frozen": "Konto ist gesperrt bis {expirationTime}.", + "admin_user_state_suspended": "Konto ist angehalten und wird gelöscht {deletionTime}.", + "admin_user_state_suspended_foreign": "Dieser Fernnutzer ist angehalten auf Deinem Server.", + "admin_user_state_self_deactivated": "Das Konto wurde vom Benutzer selbst deaktiviert und wird gelöscht {deletionTime}.", + "admin_user_state_hidden": "Das Profil ist versteckt vor unangemeldeten Benutzern.", + "admin_user_delete_account_now": "Jetzt löschen", + "admin_user_delete_account_confirmation": "Du bist dabei {name}''s Konto mit sofortiger Wirkung zu löschen.\n\nDies ist UNWIDERRUFLICH auch wenn Du eine Datenbank- und Mediendateiensicherung hast. Um fortzufahren, gib darunter den kompletten Benutzernamen mit Domäne für das zu löschende Konto ein.", + "admin_user_delete_account_title": "Benutzerkonto löschen", + "admin_audit_log_changed_user_restrictions": "{name} hat die Beschränkungen für {targetName}''s Konto geändert", + "admin_user_delete_wrong_username": "Falscher Benutzername", + "admin_user_deleted_successfully": "Das Benutzerkonto wurde gelöscht", + "admin_audit_log_deleted_user_account": "{name} hat {targetName}''s Benutzerkonto gelöscht", + "admin_report_details": "Berichtsdetails", + "admin_report_title_X": "Bericht #{id}", + "admin_report_no_actions": "Aktionsverlauf und Kommentare zu diesem Bericht werden hier angezeigt", + "admin_report_content_post": "Beitrag #{id}", + "admin_report_content_comment": "Kommentar #{id}", + "admin_report_content_message": "Nachricht #{id}", + "admin_report_content": "Inhalt", + "mark_report_unresolved": "Bericht als ungelöst markieren", + "report_action_delete_content": "Lösche Inhalt...", + "report_action_delete_content_locally": "Lösche Inhalt lokal...", + "report_action_reject": "Ablehnen", + "report_action_limit_user": "Benutzer beschränken...", + "report_action_delete_and_limit": "Lösche & Begrenzung...", + "report_state_open": "Offen", + "report_state_resolved": "Gelöst", + "report_state_rejected": "Zurückgewiesen", + "report_log_rejected": "{name} hat diesen Bericht zurückgewiesen", + "report_log_reopened": "{name} markierte diesen Bericht ungelöst", + "report_log_commented": "{name} hinterließ einen Kommentar:", + "report_content_created_at": "Erstellt", + "report_confirm_delete_content": "Sind Sie sicher, dass Sie den Inhalt dieses Berichts löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "report_delete_content_title": "Inhalt gelöscht", + "report_delete_content_checkbox": "Auch Inhalte dieses Berichts löschen", + "report_delete_content_checkbox_explanation": "Dieser Vorgang kann nicht rückgängig gemacht werden", + "report_log_deleted_content": "{name} hat den diesem Bericht beigefügten Inhalt gelöscht", + "reports_of_user": "Von {name}", + "reports_by_user": "Von {name}", + "admin_user_staff_notes_empty": "Fügen Sie einen Hinweis zu diesem Benutzer für andere Mitglieder Ihrer Serververwalter oder für sich selbst hinzu. Diese Notizen sind nur für diejenigen sichtbar, die Zugang zum Verwalten von Benutzern haben.", + "admin_user_X_staff_notes_summary": "{count, plural, =0 {Keine Verwalternotizen} one {# Verwalternotiz} other {# Verwalternotizen}}", + "admin_user_staff_note_confirm_delete": "Bist Du Dir sicher, dass Du diese Notiz löschen möchtest?", + "admin_email_domain_rules": "E-Mail Domänen", + "admin_email_X_domain_rules_summary": "{count, plural, =0 {Keine E-Mail-Domänen-Regeln} one {# E-Mail-Domänen-Regel} other {# E-Mail-Domänen-Regeln}}", + "admin_create_rule": "Erstelle Regel", + "admin_email_rule_reject": "Weise Registrierungen zurück", + "admin_email_rule_review": "Schicke Registrierungen zur manuellen Sichtung", + "admin_email_domain_rules_empty": "Es gibt keine Regeln für E-Mail-Domänen", + "admin_rule_note": "Notiz", + "admin_rule_note_explanation": "Du kannst eine Notiz für diese Regel hinzufügen für andere Mitglieder oder Serververwalter oder für Dich selbst in der Zukunft", + "admin_rule_action": "Aktion", + "admin_audit_log_created_email_rule": "{name} hat eine Regel für die E-Mail-Domäne {domain} erstellt:", + "admin_audit_log_updated_email_rule": "{name} hat eine Regel für die E-Mail-Domäne {domain} aktualisiert:", + "admin_audit_log_deleted_email_rule": "{name} hat eine Regel für die E-Mail-Domäne {domain} gelöscht:", + "err_admin_email_rule_already_exists": "Es existiert bereits eine Regel für diese Domäne", + "admin_email_rule_created": "Domänen-Regel erstellt", + "admin_email_rule_title": "E-Mail-Domänen-Regel", + "admin_confirm_delete_rule": "Bist Du sicher, dass Du diese Regel löschen willst?", + "admin_ip_rules": "IP-Adressen", + "admin_ip_rule_address": "IP-Adresse oder Subnetz", + "admin_ip_rule_expiry": "Ablaufdatum", + "admin_ip_rule_expiry_explanation": "IP-Adressen sind ein limitiertes Gut und wechseln häufig ihren Eigentümer, also wird es generell nicht empfohlen sie unbegrenzt zu blockieren", + "admin_audit_log_created_ip_rule": "{name} hat eine Regel für IP-Adresse {addressOrSubnet} erstellt:", + "admin_audit_log_updated_ip_rule": "{name} hat eine Regel für IP-Adresse {addressOrSubnet} aktualisiert:", + "admin_audit_log_deleted_ip_rule": "{name} hat eine Regel für IP-Adresse {addressOrSubnet} gelöscht:", + "admin_X_ip_rules_summary": "{count, plural, =0 {Keine Regeln für die IP-Adresse} one {# Regel für die IP-Adresse} other {# Regeln für die IP-Adresse}}", + "admin_ip_rule_until_time": "{action} bis {time}", + "admin_ip_rules_empty": "Es gibt keine Regeln für die IP-Adressen", + "admin_ip_rule_title": "IP-Adressen oder Subnetz-Regel", + "err_admin_ip_format_invalid": "Ungültige IP-Adresse. Gib eine einzelne, gültige IPv4 oder IPv6 Adresse ein oder einen Adress-Bereich in CIDR Schreibweise.", + "admin_ip_rule_created": "IP-Adress-Regel erstellt", + "summary_admin_X_signup_invites": "{count, plural, =0 {Keine Einladungen} one {# Einladung} other {# Einladungen}} in Summe auf Deinem Server", + "admin_audit_log_deleted_invite": "{name} hat {targetName}''s Einladung gelöscht", + "signup_invite_deleted": "Einladung gelöscht" } \ No newline at end of file diff --git a/src/main/resources/langs/de/email.json b/src/main/resources/langs/de/email.json index 72c88600..9c5b7741 100644 --- a/src/main/resources/langs/de/email.json +++ b/src/main/resources/langs/de/email.json @@ -1,8 +1,33 @@ { - "email_password_reset_subject": "{serverName} Passwort Zurücksetzen", - "email_password_reset_html_before": "{name}, klicke auf den folgenden Button um das Passwort für dein Konto auf {serverName} zurückzusetzen. Der Link ist 24 Stunden gültig.", - "email_password_reset_plain_before": "{name}, rufe die folgende Adresse auf um das Passwort für dein Konto auf {serverName} zurückzusetzen. Der Link ist 24 Stunden gültig.", - "email_password_reset_after": "Falls du das Zurücksetzen des Passwortes nicht angefordert hast, kannst du diese E-Mail ignorieren. Dein Konto ist sicher.", + "email_password_reset_subject": "{domain} Passwort zurücksetzen", + "email_password_reset_html_before": "{name} - klicke auf den folgenden Knopf um das Passwort für Dein Benutzerkonto auf {serverName} zurückzusetzen. Die Verknüpfung ist 24 Stunden lang gültig.", + "email_password_reset_plain_before": "{name} - rufe die folgende Adresse auf, um das Passwort für Dein Benutzerkonto auf {serverName} zurückzusetzen. Die Verknüpfung ist 24 Stunden lang gültig.", + "email_password_reset_after": "Falls Du das Zurücksetzen des Passwortes nicht angefordert hast, kannst Du diese E-Mail ignorieren. Dein Benutzerkonto ist sicher.", "email_test_subject": "Test E-Mail", - "email_test": "Falls du dies lesen kannst, wurde der Mailserver mit grosser Wahrscheinlichkeit korrekt konfiguriert" + "email_test": "Falls Du dies lesen kannst, wurde der Mailserver mit großer Wahrscheinlichkeit korrekt konfiguriert.", + "email_confirmation_subject": "Willkommen bei {domain}!", + "email_confirmation_body_html": "{name}, bedankt sich, dass Du ein Konto bei {serverName} eröffnet hast. Bitte klicke auf den Knopf hierunter, um Dein Konto zu aktivieren.", + "email_confirmation_body_plain": "{name}, bedankt sich, dass Du ein Konto bei {serverName} eröffnet hast. Bitte folge der Verknüpfung hierunter um Dein Konto zu aktivieren.", + "email_confirmation_body_button": "Aktiviere mein Konto", + "email_change_old_subject": "Deine E-Mail-Adresse bei {domain} wurde aktualisiert", + "email_change_old_body": "{name}, die E-Mail-Adresse auf Deinem Serverkonto {serverName} wurde erfolgreich geändert auf {address}. Wenn Du das selbst warst, kannst Du diese E-Mail ignorieren. Andernfalls, logge Dich sofort auf Deinem Konto ein und ändere Dein Passwort, um Dein Konto zu schützen.", + "email_change_new_subject": "Bestätige Deine neue E-Mail-Adresse für {domain}", + "email_change_new_body_html": "{name}, bitte klicke auf den Knopf hierunter, um den Wechsel Deiner E-Mail-Adresse Deines Kontos von {oldAddress} nach {newAddress} abzuschließen.", + "email_change_new_body_plain": "{name}, bitte folge der Verknüpfung hierunter, um den Wechsel Deiner E-Mail-Adresse Deines Kontos von {oldAddress} nach {newAddress} abzuschließen.", + "email_change_new_body_button": "Ändere meine E-Mail-Adresse", + "email_invite_body_start": "{name}, {inviterName} hat Dich eingeladen, um dem dezentralen Sozialen Medien Server\n{serverName} beizutreten.", + "email_invite_body_start_approved": "{name}, Deine Beitrittsanfrage zu {serverName} wurde von den Serververwaltern genehmigt.", + "email_invite_body_end_html": "Klicke auf den Knopf hierunter, um Dich zu registrieren.", + "email_invite_body_end_plain": "Folge der Verknüpfung hierunter, um mit der Registration zu beginnen.", + "email_invite_subject": "Einladung um {serverName} beizutreten", + "email_invite_subject_approved": "Deiner Anfrage um {serverName} beizutreten, wurde zugestimmt", + "begin_registration": "Beginne Registrierung", + "email_account_frozen_subject": "{domain}: Konto gesperrt", + "email_account_frozen_body": "{name}, Dein Konto auf {serverName} wurde gesperrt bis {date} wegen der Verletzung von Serverregeln. Wiederholte Verletzungen führen zu längeren Sperrungen und einer permanenten Sperre.", + "email_account_suspended_subject": "{domain}: Konto gesperrt", + "email_account_suspended_body": "{name}, Dein Konto auf {serverName} wurde wegen der Verletzung von Serverregeln gesperrt und wird gelöscht am {deletionDate}. Du kannst immer noch Deine Daten sichern, eine Umleitung setzen und Deine Dir folgenden Benutzer auf einen anderen Server umziehen.", + "email_confirmation_code": "{name}, benutze diesen Code um {action} zu bestätigen:", + "email_confirmation_code_info": "Teile diesen Code mit Niemandem.. Wenn Du ihn nicht beantragt hast, ändere sofort Dein Passwort und beende jegliche verdächtige Sitzungen.", + "email_confirmation_code_subject": "{domain}: Bestätige Aktion", + "email_confirm_action_unfreeze": "Entsperre Dein Konto" } \ No newline at end of file diff --git a/src/main/resources/langs/de/feed.json b/src/main/resources/langs/de/feed.json index 9e26dfee..ccdf9f48 100644 --- a/src/main/resources/langs/de/feed.json +++ b/src/main/resources/langs/de/feed.json @@ -1 +1,17 @@ -{} \ No newline at end of file +{ + "feed_retoot": "{author} hat einen Beitrag geteilt:", + "feed_retoot_comment": "{author} hat einen Kommentar geteilt:", + "feed_added_friend": "{author} hat {target} als Freund hinzugefügt.", + "feed_added_friend_name": "{name}", + "feed_joined_group": "{author} ist der Gruppe {target} beigetreten.", + "feed_empty": "Neuigkeiten von Deinen Freunden werden hier erscheinen.", + "feed_tab_news": "Neuigkeiten", + "feed_tab_comments": "Kommentare", + "summary_feed": "Zeige aktuelle Neuigkeiten", + "feed_joined_event": "{author} nimmt an Ereignis/Veranstaltung {target} teil.", + "feed_created_group": "{author} hat die Gruppe {target} erstellt.", + "feed_created_event": "{author} hat das Ereignis / die Veranstaltung {target} erstellt.", + "feed_added_multiple_friends": "{author} hat {count, plural, one {# Freund} other {# Freunde}} hinzugefügt:", + "feed_joined_multiple_groups": "{author} ist {count, plural, one {# Gruppe} other {# Gruppen}} beigetreten:", + "feed_joined_multiple_events": "{author} nimmt an {count, plural, one {# Ereignis / Veranstaltung} other {# Ereignissen / Veranstaltungen}} teil:" +} \ No newline at end of file diff --git a/src/main/resources/langs/de/friends.json b/src/main/resources/langs/de/friends.json index af89419f..2a7fca42 100644 --- a/src/main/resources/langs/de/friends.json +++ b/src/main/resources/langs/de/friends.json @@ -1,8 +1,8 @@ { "add_friend": "Als Freund hinzufügen", - "remove_friend": "Entfreunden", + "remove_friend": "Freundschaft beenden", "cancel_friend_request": "Anfrage abbrechen", - "err_already_friends": "Dieser Benutzer befindet sich bereits in deiner Freundesliste", + "err_already_friends": "Dieser Benutzer befindet sich bereits in Deiner Freundesliste.", "err_have_incoming_friend_req": "Du hast bereits eine eingehende Freundschaftsanfrage von diesem Benutzer.", "err_friend_req_already_sent": "Du hast bereits eine Freundschaftsanfrage an diesen Benutzer geschickt.", "err_already_following": "Du folgst diesem Benutzer bereits.", @@ -11,19 +11,50 @@ "no_friends": "Freundesliste ist leer", "friend_requests": "Freundschaftsanfragen", "no_incoming_friend_requests": "Du hast keine unbeantworteten Freundschaftsanfragen.", - "confirm_unfriend_X": "Bist du sicher, dass du mit {name} nicht mehr befreundet sein möchtest?", - "err_not_friends": "Dieser Benutzer ist nicht mit dir befreundet", + "confirm_unfriend_X": "Bist Du sicher, dass Du mit {name} nicht mehr befreundet sein möchtest?", + "err_not_friends": "Dieser Benutzer ist nicht mit Dir befreundet", "view_friends_of": "Freunde anzeigen", - "you_are_about_to_follow": "Du bist im Begriff folgenden Benutzer zu folgen:", + "you_are_about_to_follow": "Du bist im Begriff folgendem Benutzer zu folgen:", "you_are_about_to_send_friend_req": "Du bist im Begriff eine Freundschaftsanfrage an folgenden Benutzer zu schicken:", - "following": "Folgende", - "followers": "Follower", - "unfollow": "Entfolgen", - "X_friends": "{count} Freunde", - "external_X_follow_X": "{ownName}, du bist dabei {name} auf {domain} zu folgen.", - "external_X_send_friend_req_to_X": "{ownName}, du bist dabei eine Freundschaftsanfrage an {name} auf {domain} zu senden.", - "mutual_friends": "Gegenseitige Freundschaften", - "X_mutual_friends": "{count, plural, one {# gegenseitige Freundschaft} other {# gegenseitige Freundschaften}}", - "X_mutual_friends_short": "{count} gegenseitig", - "sending_friend_req_to_X": "Du schickst eine Freundschaftsanfrage an {name}." + "following": "Du folgst", + "followers": "Dir folgen", + "unfollow": "Nicht mehr folgen", + "X_friends": "{count, plural, =0 {Keine Freunde} one {# Freund} other {# Freunde}}", + "external_X_follow_X": "{ownName} - Du bist dabei {name} auf {domain} zu folgen.", + "external_X_send_friend_req_to_X": "{ownName} - Du bist dabei eine Freundschaftsanfrage an {name} auf {domain} zu senden.", + "mutual_friends": "Gemeinsame Freunde", + "X_mutual_friends": "{count, plural, one {# gemeinsame Freundschaft} other {# gemeinsame Freundschaften}}", + "X_mutual_friends_short": "Gemeinsam {count}", + "confirm_unfollow_X": "Bist Du sicher die Freundschaft mit {name} zu beenden?", + "user_friends": "{name}'s Freunde", + "summary_own_X_friends": "Du hast {numFriends, plural, =0 {keine Freunde} one {# Freund} other {# Freunde}}", + "summary_user_X_friends": "{name} hat {numFriends, plural, =0 {keine Freunde} one {# Freund} other {# Freunde}}", + "summary_X_friend_requests": "{numRequests, plural, one {# Person hat die eine Freundschaftsanfrage gesendet} other {# Leute haben Dir Freundschaftsanfragen gesendet}}", + "you_and_X_mutual": "Du und {name} haben {numFriends, plural, =0 {keine gemeinsamen Freunde} one {# gemeinsamen Freund} other {# gemeinsame Freunde}}", + "you_and_X_mutual_link": "Du und {name} haben {numFriends, plural, one {# gemeinsamen Freund} other {# gemeinsame Freunde}}", + "leave_as_follower": "Nicht mehr folgen", + "sending_friend_req_to_X": "{name} wird eine Nachricht erhalten und es wird Dir möglich sein, {gender, select, male {seine} female {ihre} other {deren}} Neuigkeiten in Deinem Nachrichtenverlauf zu sehen.", + "send_friend_req_title": "Freundschaftsanfrage senden", + "add_friend_req_message": "Füge private Nachricht hinzu", + "summary_own_X_followers": "{count, plural, =0 {Niemand folgt} one {# Person folgt} other {# Leute folgen}} Dir", + "summary_user_X_followers": "{count, plural, =0 {Niemand folgt} one {# Person folgt} other {# Leute folgen}} {name}", + "summary_own_X_follows": "Du {count, plural, =0 {folgst Niemandem} one {folgst # Person} other {folgst # Leuten}}", + "summary_user_X_follows": "{name} {count, plural, =0 {folgt Niemandem} one {folgt # Person} other {folgt # Leuten}}", + "no_followers": "Es gibt keine Folgenden", + "no_follows": "Es wird Niemandem gefolgt", + "birthday": "Geburtstag", + "birthday_turns": "Wird", + "birthday_descr_past": "Wurde {age, plural, one {# Jahr} other {# Jahre}}", + "birthday_descr_today": "Wird {age, plural, one {# Jahr} other {# Jahre}}", + "birthday_descr_future": "Wird {age, plural, one {# Jahr} other {# Jahre}}", + "friend_req_accepted": "Freundschaftsanfrage akzeptiert", + "friend_req_declined": "Freundschaftsanfrage abgelehnt", + "select_friends_title": "Wähle Freunde aus", + "friends_search_placeholder": "Beginne den Namen eines Freundes zu tippen", + "friend_list_your_friends": "Deine Freunde", + "friends_in_list": "Freunde in Liste", + "select_friends_empty_selection": "Du kannst Freunde in der Liste, hier links auswählen.", + "select_friends_button": "Wähle Freunde aus", + "X_followers": "{count, plural, =0 {Keine Folgenden} one {# Folgender} other {# Folgende}}", + "X_following": "{count, plural, =0 {Nicht irgendwem folgend} one {# folgend} other {# folgend}}" } \ No newline at end of file diff --git a/src/main/resources/langs/de/global.json b/src/main/resources/langs/de/global.json index ac6caa53..166e77cf 100644 --- a/src/main/resources/langs/de/global.json +++ b/src/main/resources/langs/de/global.json @@ -1,7 +1,7 @@ { "lang_name": "Deutsch", "send": "Senden", - "feed": "News-Feed", + "feed": "Nachrichtenverlauf", "err_user_not_found": "Benutzer nicht gefunden.", "go_back": "Zurück", "accept": "Annehmen", @@ -17,58 +17,137 @@ "err_access": "Du bist zu dieser Aktion nicht berechtigt.", "menu_profile": "Mein Profil", "menu_friends": "Meine Freunde", - "menu_news": "Meine News", + "menu_news": "Meine Neuigkeiten", "menu_settings": "Meine Einstellungen", "attach": "Anhängen", "error": "Fehler", "ok": "Ok", "cancel": "Abbrechen", "menu_notifications": "Meine Mitteilungen", - "deleted_placeholder": "[DATA EXPUNGED]", + "deleted_placeholder": "[DATEN GELÖSCHT]", "attach_photo": "Foto anhängen", - "drop_files_here": "Dateien hierherziehen", - "menu_edit": "Bearbeiten", + "drop_files_here": "Füge Dateien per Ziehen-und-Ablegen hier ein", + "menu_edit": "bearbeite", "name": "Name", - "close_this_window": "Fenster schliessen", + "close_this_window": "Fenster schließen", "like": "Like", - "liked_by_X_people": "{count, plural, one {# Person mag dies} other {# Personen mögen dies}}", + "liked_by_X_people": "{count, plural, one {# Person mag} other {# Leute mögen}} das", "open_on_server_X": "Öffnen auf {domain}", "likes_title": "Likes", "more_actions": "Mehr...", "menu_groups": "Meine Gruppen", "err_not_found": "Objekt nicht gefunden.", - "close": "Schliessen", + "close": "Schließen", "back_to_profile": "Zurück zum Profil", "back_to_group": "Zurück zur Gruppe", "back_to_event": "Zurück zum Ereignis", "edit": "Bearbeiten", "attach_menu_photo": "Foto", "attach_menu_cw": "Inhaltswarnung", - "network_error": "Netzwerkfehler. Prüfe bitte deine Verbindung und versuche es erneut.", - "domain": "Domain", - "err_flood_control": "Du versuchst es zu häuft. Bitte versuche es später noch einmal.", + "network_error": "Netzwerkfehler. Prüfe bitte Deine Verbindung und versuche es erneut.", + "domain": "Domäne", + "err_flood_control": "Du versuchst es zu häufig. Bitte versuche es später noch einmal.", "settings_saved": "Einstellungen wurden gespeichert", - "date_today": "Heute", - "date_yesterday": "Gestern", - "date_tomorrow": "Morgen", + "date_today": "heute", + "date_yesterday": "gestern", + "date_tomorrow": "morgen", "month_full": "{month, select, 1 {Januar} 2 {Februar} 3 {März} 4 {April} 5 {Mai} 6 {Juni} 7 {Juli} 8 {August} 9 {September} 10 {Oktober} 11 {November} 12 {Dezember} other {?}}", - "month_short": "{month, select, 1 {Jan} 2 {Feb} 3 {Mär} 4 {Apr} 5 {Mai} 6 {Jun} 7 {Jul} 8 {Aug} 9 {Sep} 10 {Okt} 11 {Nov} 12 {Dez} other {?}}", + "month_short": "{month, select, 1 {Jan} 2 {Febr} 3 {Mär} 4 {Apr} 5 {Mai} 6 {Jun} 7 {Jul} 8 {Aug} 9 {Sep} 10 {Okt} 11 {Nov} 12 {Dez} other {?}}", "month_standalone": "{month, select, 1 {Januar} 2 {Februar} 3 {März} 4 {April} 5 {Mai} 6 {Juni} 7 {Juli} 8 {August} 9 {September} 10 {Oktober} 11 {November} 12 {Dezember} other {?}}", - "date_time_format": "{date} at {time}", + "date_time_format": "{date} - {time}", "date_format_current_year": "{day} {month}", "date_format_other_year": "{day} {month} {year}", - "time_X_minutes_ago": "{count, plural, one {# Minute} other {# Minuten}} zuvor", + "time_X_minutes_ago": "vor {count, plural, one {# Minute} other {# Minuten}}", "time_in_X_minutes": "in {count, plural, one {# Minute} other {# Minuten}}", - "time_just_now": "gerade jetzt", + "time_just_now": "jetzt gerade", "go_up": "Nach oben", "add_comment": "Kommentieren", "about_server": "Über diesen Server", "about_stats": "Statistiken", - "about_X_users": "{count} Benutzer", + "about_X_users": "{count, plural, one {# Benutzer} other {# Benutzer}}", "about_X_posts": "{count, plural, one {# Pinnwand Beitrag} other {# Pinnwand Beiträge}}", "about_X_groups": "{count, plural, one {# Gruppe} other {# Gruppen}}", "about_contact": "Kontakt", - "about_admins": "Administratoren", + "about_admins": "Verwalter", + "about_software": "Dieser Server läuft mit {sw} Version {version}.", "about_on_this_server": "Auf diesem Server sind", - "your_account_is_banned": "Dein Konto wurde von diesem Server-Administrator verbannt." + "show_previous_comments": "Zeige vorherige Kommentare", + "comments_show_X_replies": "Zeige {count, plural, one {# Antwort} other {# Antworten}}", + "search": "Suche", + "qsearch_hint": "Fange an, einen Namen zu tippen oder füge eine Verknüpfung ein", + "unsupported_remote_object_type": "Objekte dieses Typs sind nicht unterstützt.", + "remote_object_not_found": "Entferntes Objekt nicht gefunden. Es könnte gelöscht worden sein oder Du hast eine falsche Verknüpfung angegeben.", + "remote_object_network_error": "Netzwerkfehler während ein Objekt von einem entfernten Server abgefragt wurde.", + "search_people": "Leute", + "search_groups": "Gruppen", + "search_external_objects": "Externe Objekte", + "nothing_found": "Nichts gefunden", + "in_reply_to_X": "In Antwort an {name}", + "in_reply_to_name": "{name}", + "X_days": "{count, plural, one {# Tag} other {# Tage}}", + "X_hours": "{count, plural, one {# Stunde} other {# Stunden}}", + "remove_attachment": "Entferne Anhang", + "max_attachment_count_exceeded": "Du kannst nicht mehr als {count, plural, one {# Anhang} other {# Anhänge}} hinzufügen.", + "reminder": "Erinnerung", + "birthday_reminder": "{day, select, today {Heute} tomorrow {Morgen} other {}} ist {names}'s Geburtstag.", + "birthday_reminder_name": "{name}", + "birthday_reminder_separator": ", ", + "birthday_reminder_separator_last": " und ", + "expand_text": "Erweitere Text...", + "message": "Nachricht", + "server": "Server", + "menu_events": "Meine Ereignisse", + "date_time_separator": "um", + "X_people": "{count, plural, one {Eine Person} other {# Leute}}", + "X_years": "{count, plural, one {# Jahr} other {# Jahre}}", + "date_format_month_year": "{month} {year}", + "weekday_short": "{day, select, 1 {Mo} 2 {Di} 3 {Mi} 4 {Do} 5 {Fr} 6 {Sa} 7 {So} other {?}}", + "show_more": "Zeige mehr", + "remove": "Entferne", + "copy_link": "Kopiere Verknüpfung", + "link_copied": "Verknüpfung kopiert", + "resend": "Wiederholt senden", + "index_welcome": "Herzlich Willkommen!", + "content_type_post": "Beitrag", + "content_type_comment": "Kommentar", + "content_type_photo": "Foto", + "content_type_video": "Video", + "content_type_audio": "Audiodatei", + "content_type_X_photos": "{count, plural, one {# Foto} other {# Fotos}}", + "content_type_X_videos": "{count, plural, one {# Video} other {# Videos}}", + "content_type_X_audios": "{count, plural, one {# Audiodatei} other {# Audiodateien}}", + "captcha_label": "Code aus dem Bild", + "err_wrong_captcha": "Falscher Code aus dem Bild", + "date_format_month_year_short": "{month} ''{year}", + "statistics": "Statistik", + "X_comments": "{count, plural, =0 {Keine Kommentare} one {# Kommentar} other {# Kommentare}}", + "comments_show_X_more_replies": "Zeige {count, plural, one {# weitere Antwort} other {# weitere Antworten}}", + "gender_other": "Divers", + "remote_object_loading_error": "Fehler beim laden des entfernten Objekts.", + "err_access_user_content": "Dieser Benutzer hat diese Seite versteckt.", + "menu_mail": "Meine Nachrichten", + "content_type_message": "Nachricht", + "X_users": "{count, plural, =0 {Keine Benutzer} one {# Benutzer} other {# Benutzer}}", + "change": "Ändern", + "ip_address": "IP-Adresse", + "message_from_staff": "Nachricht von den Betreibern", + "account_frozen": "Das Konto ist temporär gesperrt", + "account_suspended": "Das Konto ist gesperrt", + "account_deactivated": "Das Konto ist deaktiviert", + "account_frozen_info": "{name}, wir haben Dein Konto temporär gesperrt, weil Du die Serverregeln verletzt hast.", + "account_frozen_info_time": "Du wirst Dein Konto wieder entsperren können um {time}.", + "account_ban_message": "Dieses Konto wurde von einem Moderator gesperrt mit dem folgenden Kommentar:", + "account_frozen_unfreeze": "Um Dein Konto zu entsperren, musst Du bestätigen, dass Du sein Besitzer bist.", + "account_frozen_unfreeze_password": "Um Dein Konto zu entsperren, musst Du bestätigen, dass Du sein Besitzer bist und Du musst Dein Kennwort ändern.", + "unfreeze_my_account": "Entsperre mein Konto", + "account_suspended_info": "{name}, wir haben Dein Konto permanent gesperrt, da Du wiederholt Serverregeln gebrochen hast. Dein Konto wird am {deletionTime} gelöscht. Bis dahin, ist es Dir möglich Deine Daten herunterzuladen oder eine Umleitung zu einem anderen Server zu setzen und Deine Dir folgenden Benutzer mit zu verschieben.", + "account_ban_contact": "Wenn Du irgendwelche Fragen hast, kannst Du uns kontaktieren.", + "account_deactivated_info": "Du hast Dein Konto deaktiviert. Es wird permanent gelöscht um {deletionTime}.", + "account_reactivate": "Reaktiviere min Konto", + "action_confirmation": "Bestätige die Aktion", + "action_confirmation_text": "Wir haben Dir einen Code an {maskedEmail} gesendet. Bitte gib ihn ein um {action} zu bestätigen:", + "next": "Nächste", + "action_confirmation_incorrect_code": "Falscher Code", + "restore": "Wiederherstellen", + "cw_default": "Inhaltswarnung (CW)" } \ No newline at end of file diff --git a/src/main/resources/langs/de/groups.json b/src/main/resources/langs/de/groups.json index 7df66c89..77efdc68 100644 --- a/src/main/resources/langs/de/groups.json +++ b/src/main/resources/langs/de/groups.json @@ -4,33 +4,133 @@ "create_group": "Erstelle eine Gruppe", "create": "Erstellen", "group_name": "Name", - "group_username": "Addresse", + "group_username": "Adresse", "err_group_name_too_short": "Gruppenname ist zu kurz", - "err_group_invalid_username": "Adresse mus mit einem Buchstaben beginnen und darf nur Buchstaben, Zahlen, Punkte und Unterstriche enthalten.", + "err_group_invalid_username": "Adresse muss mit einem Buchstaben beginnen und darf nur Buchstaben, Zahlen, Punkte und Unterstriche enthalten.", "err_group_username_taken": "Diese Adresse ist bereits vergeben.", "err_group_reserved_username": "Diese Adresse ist dem System vorbehalten. Du kannst sie nicht verwenden.", - "X_members": "{count, plural, one {# Mitglied} other {# Mitglieder}}", + "X_members": "{count, plural, =0 {Keine Mitglieder} one {# Mitglied} other {# Mitglieder}}", "members": "Mitglieder", "leave_group": "Gruppe verlassen", - "join_group": "Gruppe betreten", + "join_group": "Gruppe beitreten", "err_group_not_found": "Gruppe nicht gefunden.", "err_group_already_member": "Du bist bereits Mitglied dieser Gruppe.", "err_group_not_member": "Du bist kein Mitglied dieser Gruppe.", - "group_admins": "Management", - "group_X_admins": "{count} Manager", + "group_admins": "Verwalter", + "group_X_admins": "{count, plural, =0 {Keine Verwalter} one {# Verwalter} other {# Verwalter}}", "edit_group": "Gruppe bearbeiten", "edit_event": "Ereignis bearbeiten", "about_group": "Über", "about_event": "Über", - "group_info_updated": "Gruppen-Infos wurden aktualisiert", - "event_info_updated": "Ereignis-Info wurde aktualisiert", + "group_info_updated": "Gruppen-Informationen wurden aktualisiert", + "event_info_updated": "Ereignis-Informationen wurden aktualisiert", "group_admin_demote": "Herunterstufen", - "group_admin_promote": "Zum Manager machen", - "group_access_admin_explain": "Kann Gruppen-Info bearbeiten und weitere Manager bestimmen.", - "group_access_moderator_explain": "Kann Inhalte löschen und Gruppenmitglieder blockieren.", + "group_admin_promote": "Zum Verwalter hochstufen", + "group_access_admin_explain": "Kann Gruppen-Informationen bearbeiten und weitere Verwalter bestimmen.", + "group_access_moderator_explain": "Kann Inhalte löschen und Gruppenmitglieder blockieren", "group_admin_title": "Titel", - "group_admin_title_explain": "Wird in der öffentlichen Manager Liste angezeigt.", - "group_admin_demote_confirm": "Bist du sicher, dass du {name} aus der Management-Gruppe entfernen möchtest?", + "group_admin_title_explain": "Wird in der öffentlichen Verwalter-Liste angezeigt.", + "group_admin_demote_confirm": "Bist du sicher, dass du {name} aus der Verwalter-Gruppe entfernen möchtest?", "manage_group": "Gruppe verwalten", - "X_groups": "{count, plural, one {# Gruppe} other {# Gruppen}}" + "X_groups": "{count, plural, =0 {Keine Gruppen} one {# Gruppe} other {# Gruppen}}", + "open_group": "Offene Gruppe", + "closed_group": "Geschlossene Gruppe", + "open_event": "Offenes Ereignis", + "user_groups": "{name}'s Gruppen", + "summary_own_X_groups": "Du bist Mitglied in {numGroups, plural, one {# Gruppe} other {# Gruppen}}", + "summary_user_X_groups": "{name} ist Mitglied in {numGroups, plural, one {# Gruppe} other {# Gruppen}}", + "summary_group_X_members": "{count, plural, =0 {Keine Gruppenmitglieder} one {# Gruppenmitglied} other {# Gruppenmitglieder}}", + "summary_X_managed_groups": "Du verwaltest {numGroups, plural, one {# Gruppe} other {# Gruppen}}", + "create_group_title": "Erstelle eine neue Gruppe", + "create_event": "Erstelle ein Ereignis", + "create_event_title": "Erstelle ein neues Ereignis", + "event_start_time": "Startzeit", + "event_end_time": "Endzeit", + "event_specify_end_time": "Spezifiziere eine Endzeit", + "group_description": "Gruppenbeschreibung", + "event_description": "Ereignisbeschreibung", + "events_upcoming": "Als Nächstes", + "events_past": "Vergangene", + "events_calendar": "Kalender", + "no_groups": "Es gibt keine Gruppen", + "no_events": "Es gibt keine Ereignisse", + "events": "Ereignisse", + "summary_X_upcoming_events": "Du {count, select, 0 {hast keine bevorstehenden Ereignisse} other {wirst {count, plural, one {# Ereignis besuchen} other {# Ereignisse besuchen}}}}", + "summary_X_past_events": "Du hast {count, plural, =0 {keine vergangenen Ereignisse} one {# vergangenes Ereignis} other {# vergangene Ereignisse}}", + "write_on_event_wall": "Schreibe an die Ereignis-Pinnwand...", + "leave_event": "Verlasse Ereignis", + "event_organizers": "Organisatoren", + "event_X_organizers": "{count, plural, one {# Organisator} other {# Organisatoren}}", + "err_event_end_time_before_start": "Ereignis-Endzeit muss nach der Ereignis-Startzeit liegen.", + "join_event_certain": "Ich werde dort sein", + "join_event_tentative": "Ich gehe vielleicht", + "event_joined_certain": "Du wirst dort sein.", + "event_joined_tentative": "Du bist nicht sicher.", + "tentative_members": "Nicht sicher", + "X_tentative_members": "{count} nicht sicher", + "summary_event_X_members": "{count, plural, one {Eine Person ist} other {# Leute sind}} dabei", + "summary_event_X_tentative_members": "{count, plural, one {Eine Person ist} other {# Leute sind}} sich nicht sicher", + "event_reminder": "{count, select, 1 {Ereignis} other {Ereignisse}} {events} {count, select, 1 {findet} other {finden}} {day, select, today {heute} tomorrow {morgen} other {}} statt.", + "event_reminder_separator": ", ", + "event_reminder_separator_last": " und ", + "event_reminder_today": "heute", + "event_reminder_tomorrow": "morgen", + "events_calendar_summary": "Kommende Ereignisse", + "group_size": "Größe", + "events_calendar_title": "Geburtstage und Ereignisse von Freunden", + "event_descr_past": "Das Ereignis fand um {time} statt", + "event_descr_future": "Das Ereignis wird um {time} stattfinden", + "events_for_date": "Ereignisse für {date}", + "no_events_this_month": "Es gibt keine Ereignisse in diesem Monat", + "invite_friends": "Freunde einladen", + "send_invitation": "Einladung senden", + "invitation_sent": "Einladung versandt", + "invite_already_in_group": "Dieser Benutzer ist bereits Mitglied dieser Gruppe", + "invite_already_invited_group": "Dieser Benutzer ist bereits eingeladen in diese Gruppe", + "invite_already_in_event": "Dieser Benutzer ist bereits Mitglied von diesem Ereignis", + "invite_already_invited_event": "Dieser Benutzer ist bereits eingeladen zu diesem Ereignis", + "invite_friends_title": "Freunde einladen", + "group_invitations": "Einladungen", + "decline_invitation": "Einladung ablehnen", + "summary_X_group_invites": "Du bist zu {numInvites, plural, one {# Gruppe} other {# Gruppen}} eingeladen", + "summary_X_event_invites": "Du bist zu {numInvites, plural, one {# Ereignis} other {# Ereignissen}} eingeladen", + "no_group_invites": "Du bist in keine Gruppen eingeladen", + "no_event_invites": "Du bist zu keinen Ereignissen eingeladen", + "group_invited_by_X": "Du bist von {inviter} eingeladen", + "group_invite_accepted": "Einladung akzeptiert", + "group_invite_declined": "Einladung abgelehnt", + "group_access_type": "Zugriff", + "event_access_type": "Zugriff", + "group_access_open": "Öffentlich", + "group_access_closed": "Geschlossen", + "group_access_private": "Privat", + "group_access_open_explain": "Jeder kann der Gruppe beitreten. Jeder kann das Gruppenprofil und den -inhalt sehen.", + "group_access_closed_explain": "Die Zustimmung oder Einladung eines Gruppenverwalters ist notwendig um der Gruppe beizutreten. Jeder kann das Gruppenprofil sehen, jedoch können nur die Gruppenmitglieder den Inhalt sehen.", + "event_access_open_explain": "Jeder kann dem Ereignis beitreten. Jeder kann das Ereignisprofil und den -inhalt sehen.", + "event_access_private_explain": "Die Einladung eines Ereignisverwalters wird benötigt, um dem Ereignis beizutreten. Das Ereignis ist nur für Mitglieder sichtbar.", + "group_access_private_explain": "Die Einladung eines Gruppenverwalters ist notwendig, um der Gruppe beizutreten. Niemand kann das Gruppenprofil und den -inhalt sehen. Die Gruppe ist nicht sichtbar in beigetretenen Mitgliederprofilen.", + "private_group": "Private Gruppe", + "private_event": "Privates Ereignis", + "group_private_no_access": "Dies ist eine private Gruppe. Zutritt ist nur mit Gruppenverwalter-Einladung möglich.", + "event_private_no_access": "Dies ist ein privates Ereignis. Zutritt ist nur mit Ereignisverwalter-Einladung möglich.", + "apply_to_join_group": "Bestätige zum Beitritt", + "requested_to_join": "Du hast eine Anfrage zum Beitritt in diese Gruppe gestellt", + "all_group_members": "Alle Mitglieder", + "group_join_requests": "Beitrittsanfragen", + "summary_group_X_admins": "Gruppe hat {count, plural, =0 {keine Verwalter} one {# Verwalter} other {# Verwalter}}", + "summary_event_X_admins": "Ereignis hat {count, plural, =0 {keine Organisatoren} one {# Organisator} other {# Organisatoren}}", + "group_member_removed": "Mitglied entfernt", + "group_member_blocked": "Mitglied blockiert", + "group_accept_join_request": "Annehmen", + "group_reject_join_request": "Anfrage ablehnen", + "summary_group_X_join_requests": "{count, plural, =0 {Niemand will} one {# Person will} other {# Leute wollen}} beitreten", + "group_join_request_accepted": "Anfrage akzeptiert", + "group_join_request_rejected": "Anfrage abgelehnt", + "confirm_remove_user_X": "Bist Du sicher, dass Du {name} von dieser Gruppe entfernen möchtest?", + "summary_group_X_invites": "{count, plural, =0 {Niemand ist} one {# Person ist} other {# Leute sind}} eingeladen, dieser Gruppe beizutreten", + "summary_event_X_invites": "{count, plural, =0 {Niemand ist} one {# Person ist} other {# Leute sind}} eingeladen, um diesem Ereignis beizutreten", + "cancel_invitation": "Einladung abbrechen", + "invitation_canceled": "Einladung abgebrochen", + "confirm_leave_group": "Dies ist eine {type, select, CLOSED {geschlossene} PRIVATE {private} other {}} Gruppe. Wenn Du sie verlässt, könnte es schwierig sein, wieder beizutreten.\n\nBist Du Dir sicher, diese Gruppe zu verlassen?", + "confirm_leave_event": "Dies ist ein privates Ereignis. Wenn Du es verlässt, kann es schwierig sein, wieder beizutreten.\n\nBist Du Dir sicher, dass Du dieses Ereignis verlassen willst?" } \ No newline at end of file diff --git a/src/main/resources/langs/de/mail.json b/src/main/resources/langs/de/mail.json index 9e26dfee..b7b73712 100644 --- a/src/main/resources/langs/de/mail.json +++ b/src/main/resources/langs/de/mail.json @@ -1 +1,38 @@ -{} \ No newline at end of file +{ + "mail_inbox": "Empfangen", + "mail_outbox": "Gesendet", + "mail_inbox_summary": "Du hast {count, plural, =0 {keine Nachrichten} one {# Nachricht} other {# Nachrichten}} empfangen", + "mail_outbox_summary": "Du hast {count, plural, =0 {keine Nachrichten} one {# Nachricht} other {# Nachrichten}} gesendet", + "mail_inbox_empty": "Du hast überhaupt noch keine Nachrichten erhalten.", + "mail_outbox_empty": "Du hast überhaupt noch keine Nachrichten gesendet.", + "mail_inbox_title": "Empfangene Nachrichten", + "mail_outbox_title": "Gesendete Nachrichten", + "mail_message_sent": "Deine Nachricht wurde an {name} gesendet.", + "mail_message_sent_multi": "Deine Nachricht wurde an {name} und {moreCount, plural, one {# weitere Person} other {# mehr Leute}} gesendet.", + "mail_compose": "Verfasse eine Nachricht", + "mail_tab_compose": "Neue Nachricht", + "mail_tab_message_view": "Nachricht", + "mail_message_title_incoming": "Nachricht von {name}", + "mail_message_title_outgoing": "Nachricht für {name}", + "mail_message": "Nachricht", + "mail_from": "Von", + "mail_to": "An", + "mail_cc": "Kopie an", + "mail_subject": "Betreff", + "mail_edited_at": "Bearbeitet {time}", + "mail_message_deleted": "Nachricht gelöscht.", + "restore_message": "Wiederherstellen.", + "restore_or_delete_for_peer": "Wiederherstellen oder Löschen für {name}.", + "restore_or_delete_for_peer_multi": "Wiederherstellen oder Löschen von Empfängern.", + "mail_show_history": "Zeige Gesprächsverlauf mit {name}", + "mail_specify_subject": "Gib einen Betreff an", + "in_reply_to_message": "In Antwort von {name}''s Nachricht", + "mail_in_reply_to": "In Antwort an", + "mail_in_reply_to_post": "{name}''s Beitrag", + "mail_in_reply_to_comment": "{name, inflect, genitive}''s Kommentar", + "mail_in_reply_to_own_post": "Dein Beitrag", + "mail_in_reply_to_own_comment": "Dein Kommentar", + "profile_write_message": "Sende eine Nachricht", + "messages_title": "Nachrichten", + "mail_conversation_history": "Gesprächsverlauf" +} \ No newline at end of file diff --git a/src/main/resources/langs/de/notifications.json b/src/main/resources/langs/de/notifications.json index f60e2969..20b7b207 100644 --- a/src/main/resources/langs/de/notifications.json +++ b/src/main/resources/langs/de/notifications.json @@ -1,16 +1,18 @@ { - "notification_replied_to_post": "hat deinen Beitrag kommentiert", - "notification_replied_to_comment": "hat auf dein Kommentar geantwortet", - "notification_mentioned_in_post": "hat dich in einem Beitrag erwähnt", - "notification_mentioned_in_comment": "hat dich in einem Kommentar erwähnt", - "notification_liked_post": "gefällt dein Beitrag", - "notification_liked_comment": "gefällt dein Kommentar", - "notification_reposted_post": "hat deinen Beitrag geteilt", - "notification_reposted_comment": "hat dein Kommentar geteilt", - "notification_posted_on_wall": "hat auf deine Pinnwand geposted", - "notification_invite_signup": "hat sich aufgrund deiner Einladung angemeldet", - "notification_follow": "folgt dir", - "notification_friend_req_accept": "hat deine Freundschaftsanfrage akzeptiert", + "notification_replied_to_post": "hat Deinen Beitrag kommentiert", + "notification_replied_to_comment": "hat auf Deinen Kommentar geantwortet", + "notification_mentioned_in_post": "hat Dich in einem Beitrag erwähnt", + "notification_mentioned_in_comment": "hat Dich in einem Kommentar erwähnt", + "notification_liked_post": "gefällt Dein Beitrag", + "notification_liked_comment": "gefällt Dein Kommentar", + "notification_reposted_post": "hat Deinen Beitrag geteilt", + "notification_reposted_comment": "hat Deinen Kommentar geteilt", + "notification_posted_on_wall": "hat an Deine Pinnwand geschrieben", + "notification_invite_signup": "hat sich aufgrund Deiner Einladung angemeldet", + "notification_follow": "folgt Dir", + "notification_friend_req_accept": "hat Deine Freundschaftsanfrage akzeptiert", "no_notifications": "Es liegen keine Benachrichtigungen vor", - "notifications": "Benachrichtigungen" + "notifications": "Benachrichtigungen", + "notifications_empty": "Deine Benachrichtigung wird hier erscheinen.", + "summary_notifications": "Zeige die neuesten Benachrichtigungen" } \ No newline at end of file diff --git a/src/main/resources/langs/de/profile.json b/src/main/resources/langs/de/profile.json index 7c99b78b..047822bd 100644 --- a/src/main/resources/langs/de/profile.json +++ b/src/main/resources/langs/de/profile.json @@ -1,13 +1,20 @@ { - "write_on_X_wall": "Schreibe etwas für {name}...", + "write_on_X_wall": "Schreibe etwas an {name}'s Pinnwand...", "birth_date": "Geburtsdatum", - "write_on_own_wall": "Was gibt's Neues?", + "write_on_own_wall": "Was gibt es Neues?", "profile_about": "Über", - "X_is_your_friend": "{name} ist dein Freund", + "X_is_your_friend": "{name} ist Dein Freund", "you_sent_friend_req_to_X": "Du hast {name} eine Freundschaftsanfrage gesendet", - "X_sent_you_friend_req": "{name} hat dir eine Freundschaftsanfrage gesendet", + "X_sent_you_friend_req": "{name} hat Dir eine Freundschaftsanfrage gesendet", "you_are_following_X": "Du folgst {name}", - "X_is_following_you": "{name} folgt dir", - "waiting_for_X_to_accept_follow_req": "Du wartest auf {name}, um deine Folgeanfrage zu akzeptieren", - "incomplete_profile": "Dieses Profil ist möglicherweise unvollständig." + "X_is_following_you": "{name} folgt Dir", + "waiting_for_X_to_accept_follow_req": "Du wartest auf {name}, um Deine Folgeanfrage zu akzeptieren", + "incomplete_profile": "Dieses Profil ist möglicherweise unvollständig.", + "this_is_you": "(das bist Du)", + "profile_moved": "{name} benutzt jetzt ein anderes Profil:", + "profile_moved_link": "{name} auf {domain}", + "profile_deactivated": "Dieses Profil wurde von seinem Besitzer gelöscht.", + "profile_hidden": "Verstecktes Profil", + "profile_hidden_info": "Du musst angemeldet sein, um dieses Profil ansehen zu können.", + "profile_banned": "Dieses Konto wurde von den Serververwaltern gebannt." } \ No newline at end of file diff --git a/src/main/resources/langs/de/reporting.json b/src/main/resources/langs/de/reporting.json index 9e26dfee..14a39e34 100644 --- a/src/main/resources/langs/de/reporting.json +++ b/src/main/resources/langs/de/reporting.json @@ -1 +1,20 @@ -{} \ No newline at end of file +{ + "report": "Melde", + "report_title_post": "Melde einen Beitrag", + "report_title_comment": "Melde einen Kommentar", + "report_title_user": "Melde ein Profil", + "report_title_group": "Melde eine Gruppe", + "report_title_event": "Melde ein Ereignis / eine Veranstaltung", + "report_text_post": "Was ist falsch an diesem Beitrag?", + "report_text_comment": "Was ist falsch an diesem Kommentar?", + "report_text_user": "Was ist falsch an diesem Profil?", + "report_text_group": "Was ist falsch an dieser Gruppe?", + "report_text_event": "Was ist falsch an diesem Ereignis / dieser Veranstaltung?", + "report_placeholder_profile": "Zum Beispiel: ein explizites Profilbild", + "report_placeholder_content": "Zum Beispiel: Spam", + "report_sent": "Meldungsbericht gesendet", + "report_forward_to_domain": "Anonym weitergeleitet an {domain}", + "report_submitted": "Dein Meldungsbericht wurde an die Serververwalter übermittelt.", + "report_title_message": "Melde eine Nachricht", + "report_text_message": "Was ist falsch an dieser Nachricht?" +} \ No newline at end of file diff --git a/src/main/resources/langs/de/settings.json b/src/main/resources/langs/de/settings.json index 9025d576..d0e1c77a 100644 --- a/src/main/resources/langs/de/settings.json +++ b/src/main/resources/langs/de/settings.json @@ -16,15 +16,15 @@ "register": "Registrieren", "email": "E-Mail", "username": "Benutzername", - "username_explain": "Kann Buchstaben, Zahlen, Punkte und Unterstriche enthalten.", + "username_explain": "Kann Buchstaben, Zahlen, Punkte und Unterstriche enthalten!", "create_invitation": "Neue Einladung erstellen", "no_invitations": "Es liegen keine Einladungen vor", "err_reg_invalid_username": "Benutzername muss mit einem Buchstaben beginnen und darf nur Buchstaben, Zahlen, Punkte und Unterstriche enthalten.", "err_reg_username_taken": "Dieser Benutzername ist bereits vergeben.", "err_reg_reserved_username": "Dieser Benutzername ist dem System vorbehalten. Du kannst ihn nicht nutzen.", - "err_password_short": "Mindespasswortlänge ist 4 Zeichen.", + "err_password_short": "Die minimale Länge Deines Passwortes sind 4 Zeichen.", "err_passwords_dont_match": "Passwort und Passwortbestätigung sind nicht identisch.", - "err_invalid_email": "Ungültige E-Mail.", + "err_invalid_email": "Ungültige E-Mail-Adresse.", "err_name_too_short": "Der Name ist zu kurz. Minimallänge ist zwei Zeichen.", "err_invalid_invitation": "Ungültiger Einladungscode.", "invitation_created": "Einladung erstellt", @@ -35,7 +35,7 @@ "avatar_updated": "Profilbild aktualisiert", "language": "Sprache", "login_title": "Anmelden", - "login_incorrect": "Falsche E-Mail/Benutzername oder Passwort", + "login_incorrect": "Falsche E-Mail-Adresse/Benutzername oder Passwort", "login_needed": "Du musst angemeldet sein, um diese Aktion durchführen zu können", "edit_profile": "Profil bearbeiten", "profile_edit_basic": "Grundlegende Informationen", @@ -45,36 +45,119 @@ "gender_none": "(nicht angegeben)", "upload_avatar": "Profilbild hochladen", "profile_info_updated": "Dein Profil wurde aktualisiert", - "profile_pic_select_square_version": "Wähle die quadratische Version deines Bildes aus, welche neben deinen Beiträgen und Kommentaren angezeigt wird.", + "profile_pic_select_square_version": "Wähle die quadratische Version Deines Bildes aus, welche neben Deinen Beiträgen und Kommentaren angezeigt wird.", "drag_or_choose_file": "Wähle eine Datei aus oder ziehe sie in dieses Fenster", "choose_file": "Wähle eine Datei aus", "picture_too_wide": "Dein Bild ist zu breit.", "picture_too_narrow": "Dein Bild ist zu schmal.", "error_loading_picture": "Fehler beim Laden des Bildes.", "remove_profile_picture": "Profilbild entfernen", - "confirm_remove_profile_picture": "Bist du sicher, dass du das Profilbild entfernen möchtest?", - "choose_file_mobile": "Wähle eine Datei von deinem Gerät", + "confirm_remove_profile_picture": "Bist Du sicher, dass Du Dein Profilbild entfernen möchtest?", + "choose_file_mobile": "Wähle eine Datei von Deinem Gerät", "middle_name_or_nickname": "Zweiter Vorname oder Nickname", "maiden_name": "Geburtsname", - "signups_closed": "Auf diesem Server ist keine Registrierung möglich.", + "signups_closed": "Registrierungen auf diesem Server sind geschlossen.", "settings_general": "Allgemein", "settings_blocking": "Blockiert", "settings_blocked_users": "Blockierte Benutzer", "settings_blocked_domains": "Blockierte Domains", "settings_no_blocked_users": "Du hast niemanden blockiert.", "settings_no_blocked_domains": "Du hast keine Domains blockiert.", - "unblock": "Entblocken", + "unblock": "Entblockieren", "block": "Blockieren", "block_user_X": "Blockiere {name}", "unblock_user_X": "{name} entblocken", - "confirm_block_user_X": "Bist du sicher, dass du den Benutzer {name} blockieren möchtest?", - "confirm_unblock_user_X": "Bist du sicher, dass du den Benutzer {name} entblocken möchtest?", + "confirm_block_user_X": "Bist Du sicher, dass Du den Benutzer {name} blockieren möchtest?", + "confirm_unblock_user_X": "Bist Du sicher, dass Du den Benutzer {name} entblocken möchtest?", "block_a_domain": "Eine Domain blockieren", "err_domain_already_blocked": "Du hast diese Domain bereits blockiert.", "confirm_unblock_domain_X": "Bist du sicher, dass du die Domain {domain} entblocken möchtest?", "forgot_password": "Passwort vergessen?", "reset_password": "Mein Passwort zurücksetzen", - "reset_password_title": "Setze dein Passwort zurück", + "reset_password_title": "Setze Dein Passwort zurück", "password_reset_account_not_found": "Es konnte kein passendes Konto gefunden werden.", - "password_reset_sent": "Prüfe bitte deine E-Mails für weitere Anweisungen." + "password_reset_sent": "Prüfe bitte Deine E-Mails für weitere Anweisungen.", + "account_inactive_confirm_email": "Du musst Deine E-Mail-Adresse bestätigen, bevor Du Dein Konto benutzen kannst. Bitte klicke auf die Verknüpfung die Du an die Adresse {address} erhalten hast.", + "resend_confirm_email": "Bestätigungs-E-Mail erneut senden", + "change_email_address": "E-Mail-Adresse ändern", + "change_email_title": "E-Mail ändern", + "change_email": "E-Mail ändern", + "account_activation": "Konto aktivieren", + "email_confirmation_resent": "Eine neue Bestätigungs-E-Mail wurde an {address} gesendet.\n\nWenn das nicht die richtige E-Mail-Adresse ist, Ändere sie.", + "new_email_address": "Neue E-Mail-Adresse", + "err_reg_email_taken": "Es gibt bereits ein Konto mit dieser E-Mail-Adresse.", + "email_confirmed_activated": "Du hast Dein Konto aktiviert. Herzlich Willkommen!", + "email_confirmed_changed": "Du hast Deine E-Mail-Adresse geändert.", + "proceed_fill_profile": "Richte Dein Konto ein", + "err_email_link_invalid": "Diese E-Mail-Verknüpfung ist falsch.", + "err_email_already_activated": "Dein Konto wurde bereits aktiviert.", + "pending_email_change": "Du hast bereits vorher beantragt, Deine E-Mail-Adresse {address} zu ändern. Jedoch hast Du nicht auf die Verknüpfung geklickt, die in der an diese E-Mail-Adresse gesendete E-Mail war.\nWenn Du die Bestätigungs-Verknüpfung noch nicht erhalten hast, kannst Du sie hier Erneut senden oder den E-Mail-Adressen Änderungsantrag abbrechen.", + "change_email_sent": "Bitte folge der Verknüpfung, die in der an die neue E-Mail-Adresse gesendete E-Mail verschickt wurde, um das Ändern Deiner E-Mail-Adresse für Dein Benutzerkonto zu abzuschließen.", + "current_email": "Aktuelle E-Mail-Adresse", + "email_confirmation_resent_short": "Eine neue E-Mail wurde an {address} gesendet.", + "invite_users": "Einladen", + "summary_sent_X_signup_invites": "Du hast {count, plural, =0 {gar keine Einladungen erstellt} one {# Einladung erstellt} other {# Einladungen erstellt}}", + "invite_by_email": "Verschicke eine Einladung", + "invite_create_link": "Erstelle eine Verknüpfung", + "my_invites": "Meine Einladungen", + "invited_users": "Eingeladene Leute", + "no_invites": "Es gibt keine Einladungen", + "invite_created_at": "Erstellt am {date}", + "X_signups_remaining": "{count, plural, one {# Anmeldung verbleibt} other {# Anmeldungen verbleiben}}", + "confirm_delete_invite": "Bist Du sicher, dass Du diese Einladung löschen möchtest?", + "invite_add_friend": "Dich automatisch als Freund hinzufügen lassen", + "err_email_already_invited": "Es gibt bereits eine schwebende Einladung für diese E-Mail-Adresse.", + "email_invite_sent": "Einladung gesendet", + "email_invite_resent": "Einladung erneut gesendet", + "invite_create_link_title": "Erstelle eine Einladungs-Verknüpfung", + "invite_signup_count": "Registrierungszähler", + "invite_signup_count_explain": "Wieviele Leute sollen sich mit dieser Verknüpfung registrieren können, bevor diese stoppt.", + "invite_link_created": "Einladungsverknüpfung erstellt", + "summary_invited_X_people": "Du hast {count, plural, =0 {niemanden} one {# Person} other {# Leute}} für diesen Server eingeladen", + "no_invited_users": "Niemand hat sich für Deine Einladungen registriert", + "invited_people_title": "Leute, die Du eingeladen hast", + "request_invitation": "Beantrage eine Einladung", + "request_invitation_reason": "Warum willst Du uns beitreten", + "request_invitation_reason_explain": "Dies hilft uns die Gründe Deines Antrags zu verstehen.", + "manual_signup_approval_explain": "Registrierungen auf diesem Server sind gesperrt, jedoch kannst Du Dich für eine Einladung bewerben. Wenn die Serververwalter Deiner Bewerbung stattgeben, wirst Du eine Einladung an die E-Mail-Adresse erhalten, die Du hier angibst.", + "signup_title": "Registrieren", + "err_request_invite_reason_empty": "Bitte gib an, warum Du uns beitreten willst", + "signup_request_submitted": "Du hast eine Einladung zum Beitritt zu diesem Server angefragt. Wenn Die Serververwalter Deiner Bewerbung stattgeben, wirst Du eine Einladung an die von Dir angegebene E-Mail-Adresse erhalten.", + "settings_privacy": "Privatsphäre", + "privacy_wall_posting": "Wer kann an meine Pinnwand beitragen", + "privacy_wall_others_posts": "Wer sieht Beiträge Anderer an meiner Pinnwand", + "privacy_wall_commenting": "Wer kann meine Beiträge kommentieren", + "privacy_group_invites": "Wer kann mich zu Gruppen und Ereignissen einladen", + "privacy_mail": "Wer kann mir private Nachrichten senden", + "privacy_value_everyone": "Alle Benutzer", + "privacy_value_friends": "Nur Freunde", + "privacy_value_friends_of_friends": "Freunde von Freunden", + "privacy_value_no_one": "Niemand", + "privacy_value_only_me": "Nur ich", + "privacy_value_everyone_except": "Jeder, außer...", + "privacy_value_certain_friends": "Einige Freunde", + "privacy_settings_title": "Privatsphären-Einstellungen", + "privacy_allowed_title": "Wer kann Zugriff erhalten?", + "privacy_denied_title": "Wer kann keinen Zugriff erhalten?", + "privacy_allowed_to_X": "Zugriff erlaubt für {value}.", + "privacy_value_to_everyone": "Alle Benutzer", + "privacy_value_to_friends": "Nur Freunde", + "privacy_value_to_friends_of_friends": "Freunde von Freunden", + "privacy_value_to_certain_friends": "Einige Freunde", + "privacy_enter_friend_name": "Gib den Namen des Freundes ein", + "privacy_settings_saved": "Deine neuen Privatsphären-Einstellungen wurden gespeichert", + "privacy_settings_value_except": ", außschließend ", + "privacy_settings_value_certain_friends_before": ": ", + "privacy_settings_value_except_name": "{name}", + "privacy_settings_value_name_separator": ", ", + "settings_sessions": "Aktivitätenverlauf", + "settings_activity_access_type": "Zugriffsart", + "settings_activity_access_time": "Zeit", + "settings_activity_web": "{browserName} {browserVersion} auf {osName}", + "unknown_browser": "Unbekannter Browser", + "settings_deactivate_confirm": "Bist Du Dir sicher, dass Du Dein Benutzerkonto löschen möchtest?\n\nDein Benutzerkonto wird deaktiviert und erst nach {timeInterval} permanent gelöscht. Solltest Du Deine Entscheidung während dieser Zeit ändern, hast Du die Möglichkeit, Dein Benutzerkonto ohne Datenverlust zu reaktivieren.", + "settings_deactivate_account": "Du kannst Dein Konto löschen.", + "settings_reactivate_confirm": "Gib Dein Passwort ein, um Dein Benutzerkonto wieder zu öffnen und um die Löschung zurückzunehmen.", + "settings_reactivate_title": "Konto wiedereröffnen", + "err_reg_email_domain_not_allowed": "Anmeldungen mit E-Mail-Adressen von diesem Anbieter sind auf diesem Server nicht gestattet" } \ No newline at end of file diff --git a/src/main/resources/langs/de/wall.json b/src/main/resources/langs/de/wall.json index 164be5ad..85bfb017 100644 --- a/src/main/resources/langs/de/wall.json +++ b/src/main/resources/langs/de/wall.json @@ -3,22 +3,65 @@ "wall_all_posts": "Beiträge", "wall_posts_of_X": "Beiträge von {name}", "wall_go_to_X_wall": "Gehe zur Pinnwand von {name}", - "wall_of_X": "Wall von {name}", + "wall_of_X": "Pinnwand von {name}", "wall_X_and_Y": "{name1} und {name2}", "wall_my_posts": "Meine Beiträge", "delete_post": "Beitrag löschen", "delete_post_confirm": "Bist du sicher, dass du den Beitrag löschen möchtest?", "wall": "Pinnwand", - "X_posts": "{count, plural, one {# Beitrag} other {# Beiträge}}", + "X_posts": "{count, plural, =0 {Keine Beiträge} one {# Beitrag} other {# Beiträge}}", "delete_reply": "Kommentar löschen", - "delete_reply_confirm": "Bist du sicher, dass du diesen Kommentar löschen möchtest?", + "delete_reply_confirm": "Bist Du sicher, dass Du diesen Kommentar löschen möchtest?", "add_reply": "Antworten", - "comment_placeholder": "Schreibe ein Kommentar...", + "comment_placeholder": "Schreibe einen Kommentar...", "wall_post_title": "Beitrag", - "wall_of_group": "Gruppen Wand", + "wall_of_group": "Gruppen-Pinnwand", "write_on_group_wall": "Schreibe auf Gruppen-Pinnwand...", "server_of_user_X_no_wall": "Der Server von {name} unterstützt keine Pinnwand-Beiträge.", "server_of_group_no_wall": "Der Server dieser Gruppe untersützt keine Pinnwand-Beiträge.", "post_form_cw": "Inhaltswarnung", - "post_form_cw_placeholder": "Titel" + "post_form_cw_placeholder": "Titel", + "poll": "Umfrage", + "submit_vote": "Abstimmung", + "X_people_voted": "{count, plural, one {# Person} other {# Leute}} stimmten ab.", + "anonymous_poll": "Anonyme Umfrage", + "public_poll": "Öffentliche Umfrage", + "poll_voting_until": "Abstimmung endet am {date}.", + "poll_expired": "Abstimmung endete am {date}.", + "err_poll_already_voted": "Du hast in dieser Umfrage schon abgestimmt.", + "err_poll_expired": "Abstimmen in dieser Umfrage wurde bereits beendet.", + "attach_menu_poll": "Umfrage", + "create_poll_question": "Frage", + "create_poll_options": "Umfrageoptionen", + "create_poll_add_option": "Füge eine Option hinzu", + "create_poll_delete_option": "Entferne diese Option", + "create_poll_multi_choice": "Mehrfachauswahl", + "create_poll_anonymous": "Anonyme Abstimmung", + "create_poll_time_limit": "Begrenze Abstimmungszeit", + "poll_option_votes_empty": "Niemand hat für diese Option gestimmt.", + "likes_empty": "Keine Likes vorhanden.", + "X_people_voted_title": "{count, plural, one {# Person stimmte} other {# Leute stimmten}} ab", + "wall_empty": "Niemand hat irgendetwas hier geschrieben... Noch nicht.", + "wall_post_rejected": "Der entfernte Server hat diesen Beitrag abgelehnt.", + "total_X_posts": "{count, plural, one {# Beitrag} other {# Beiträge}} total", + "editing_post": "Editiere Beitrag", + "editing_comment": "Editiere Kommentar", + "edit_poll_warning": "Wenn Du irgendetwas in dieser Umfrage änderst, werden alle Stimmen zurückgesetzt.", + "attach_menu_graffiti": "Graffiti", + "graffiti_editor_title_user": "Dein Grafitti auf {name}''s Pinnwand", + "graffiti_editor_title_group": "Dein Grafitti auf Pinnwand \"{name}\"", + "graffiti_clear": "Fange nochmal neu an", + "graffiti_undo": "Letzte Aktion rückgängig machen", + "graffiti_clear_confirm": "Bist Du sicher, dieses Grafitti zu löschen?", + "graffiti_close_confirm": "Bist Du sicher, das Zeichnen zu beenden, ohne es abzuspeichern?", + "confirm_title": "Bestätigung", + "graffiti_color": "Farbe", + "graffiti_thickness": "Stiftdicke", + "graffiti_opacity": "Intensität", + "graffiti_on_user_X_wall": "Graffiti auf {name}''s Pinnwand", + "graffiti_on_group_X_wall": "Graffiti auf Pinnwand \"{name}\"", + "post_visible_to_followers": "Dieser Beitrag ist nur für {name}''s Folgende sichtbar", + "post_visible_to_followers_mentioned": "Dieser Beitrag ist nur für {name}''s Folgende und erwähnte Leute sichtbar", + "post_visible_to_friends": "Dieser Beitrag ist nur für {name}''s Freunde sichtbar", + "expand_all_cws": "Erweitere alle Inhaltswarnungen" } \ No newline at end of file diff --git a/src/main/resources/langs/en/admin.json b/src/main/resources/langs/en/admin.json index bddc28ed..cb635cd7 100644 --- a/src/main/resources/langs/en/admin.json +++ b/src/main/resources/langs/en/admin.json @@ -10,11 +10,8 @@ "invited_by": "Invited by", "signup_date": "Sign up date", "actions": "Actions", - "access_level": "Access level", - "choose_access_level_for_X": "Choose access level for {name}", - "access_level_regular": "Regular user", - "access_level_moderator": "Moderator", - "access_level_admin": "Administrator", + "role": "Role", + "choose_role_for_X": "Choose role for {name}", "admin_signup_mode": "Sign up mode", "admin_signup_mode_open": "Open", "admin_signup_mode_invite": "Invite-only", @@ -34,11 +31,6 @@ "admin_server_short_description": "Short description", "admin_server_policy": "Policy", "admin_server_info_html_explain": "You can use HTML in the description and policy fields.", - "admin_ban": "Ban", - "admin_unban": "Unban", - "admin_ban_X_confirm": "Are you sure you want to ban {name}'s account?", - "admin_unban_X_confirm": "Are you sure you want to unban {name}'s account?", - "ban_reason": "Reason for ban", "sync_friends_and_groups": "Sync friends & groups", "sync_members": "Sync member list", "sync_content": "Sync content", @@ -65,13 +57,7 @@ "report_comment": "Additional information from user", "private_post_warning_title": "You aren't supposed to have access to this post", "private_group_post_warning": "This post is in a {groupType, select, private {private} other {closed}} group of which you are not a member. You are only able to see it because you''ve opened it from a report.", - "mark_report_resolved": "Mark as resolved", - "report_action_delete_post": "Delete post", - "report_action_delete_post_locally": "Delete post locally", - "report_action_delete_comment": "Delete comment", - "report_action_delete_comment_locally": "Delete post locally", "report_action_add_cw": "Add CW", - "report_action_suspend": "Suspend...", "admin_federation": "Federation", "search_server_domain": "Server domain", "summary_X_servers": "{count, plural, one {# server} other {# servers}} total", @@ -111,5 +97,182 @@ "admin_captcha_signup_form": "In the signup form", "server_stats_activities_sent": "Activities sent", "server_stats_activities_received": "Activities received", - "server_stats_delivery_errors": "Delivery errors" + "server_stats_delivery_errors": "Delivery errors", + "menu_users": "Users", + "menu_access": "Federation & Access", + "menu_stats": "Statistics", + "admin_invites": "Invitations", + "role_none": "(no role)", + "role_owner": "Owner", + "role_admin": "Admin", + "role_moderator": "Moderator", + "admin_server_settings": "Server", + "admin_roles": "Roles", + "admin_roles_summary": "{count, plural, one {# role} other {# roles}}", + "admin_create_role": "Create a new one", + "admin_permission_superuser": "Superuser", + "admin_permission_server_settings": "Manage server settings", + "admin_permission_rules": "Manage server rules", + "admin_permission_roles": "Manage user roles", + "admin_permission_audit_log": "View audit log", + "admin_permission_manage_users": "Manage users", + "admin_permission_user_access": "Manage user access", + "admin_permission_reports": "Manage reports", + "admin_permission_federation": "Manage federation", + "admin_permission_blocking_rules": "Manage blocking rules", + "admin_permission_invites": "Manage invitations", + "admin_permission_announcements": "Manage announcements", + "admin_permission_delete_users": "Delete user accounts and data", + "admin_permission_manage_groups": "Manage groups and events", + "admin_visible_in_staff": "Shown in server staff", + "admin_permission_descr_superuser": "Has all permissions and can do everything", + "admin_permission_descr_server_settings": "Allows changing server name, description, signup mode, and other settings", + "admin_permission_descr_rules": "Allows editing, adding, and removing server rules", + "admin_permission_descr_roles": "Allows editing, creating, and assigning user roles equal or lower than theirs", + "admin_permission_descr_audit_log": "Allows viewing the log of all administrative actions performed on the server", + "admin_permission_descr_manage_users": "Allows viewing users' details and performing moderation actions against their accounts", + "admin_permission_descr_user_access": "Allows resetting users' passwords and changing their emails", + "admin_permission_descr_reports": "Allows viewing and acting on reports", + "admin_permission_descr_federation": "Allows changing the federation restrictions of this server", + "admin_permission_descr_blocking_rules": "Allows blocking e-mail providers and IP addresses", + "admin_permission_descr_invites": "Allows viewing all active invite links on the server and deactivating them", + "admin_permission_descr_announcements": "Allows creating and editing announcements on this server", + "admin_permission_descr_delete_users": "Allows deleting user accounts immediately without having to wait 30 days", + "admin_permission_descr_manage_groups": "Allows viewing details of groups and performing moderation actions against them", + "admin_visible_in_staff_descr": "Show users with this role under \"Administration\" in \"About this server\"", + "admin_delete_role": "Delete role", + "admin_delete_role_confirm": "Are you sure you want to remove this role from all users who have it assigned and delete it? This action can not be undone.", + "admin_role_name": "Role name", + "admin_permissions": "Permissions", + "admin_role_X_saved": "Role \"{name}\" was saved", + "admin_role_X_created": "Role \"{name}\" was created", + "admin_no_permissions_selected": "Select at least one permission", + "admin_edit_role_title": "Edit role", + "admin_create_role_title": "Create role", + "admin_audit_log": "Audit log", + "admin_audit_log_summary": "{count, plural, =0 {No log entries} one {# log entry total} other {# log entries total}}", + "admin_audit_log_empty": "Records of administrative actions will appear here", + "admin_audit_log_created_role": "{name} created role \"{roleName}\"", + "admin_audit_log_edited_role": "{name} edited role \"{roleName}\"", + "admin_audit_log_deleted_role": "{name} deleted role \"{roleName}\"", + "admin_audit_log_assigned_role": "{name} assigned role \"{roleName}\" to {targetName}", + "admin_audit_log_unassigned_role": "{name} removed {targetName}''s role", + "admin_user_location_any": "Any location", + "admin_user_location_local": "Local", + "admin_user_location_remote": "Remote", + "summary_X_users": "{count, plural, one {# user} other {# users}} total", + "summary_X_users_found": "{count, plural, =0 {No users} one {# user} other {# users}} found", + "admin_user_email_domain": "E-mail domain", + "admin_user_ip_or_subnet": "IP address or subnet", + "any_role": "(any)", + "search_users": "Search users", + "no_users_found": "No users matching these criteria were found", + "admin_last_user_activity": "Last active", + "admin_manage_user": "Manage user", + "admin_user_staff_notes": "Staff notes", + "admin_account_id_tooltip": "Local account ID", + "admin_ap_actor_id_tooltip": "Remote ActivityPub actor ID", + "admin_account_activation_status": "Activation", + "admin_account_activated": "E-mail confirmed", + "admin_account_email_unconfirmed": "E-mail not confirmed", + "admin_account_email_change_pending": "E-mail change to {newEmail} was requested, new e-mail not confirmed", + "admin_others_with_this_domain": "Others with this domain", + "admin_actor_last_updated": "Last updated", + "admin_end_session": "End session", + "admin_audit_log_activated_account": "{name} activated {targetName}''s account", + "admin_audit_log_changed_email": "{name} changed {targetName}''s email", + "admin_audit_log_reset_password": "{name} reset {targetName}''s password", + "admin_audit_log_ended_session": "{name} terminated one of {targetName}''s sessions", + "admin_session_terminated": "Session terminated", + "admin_user_restrictions": "Restrictions", + "admin_user_change_restrictions": "Change restrictions...", + "admin_ban_user_title": "User restrictions", + "admin_user_no_restrictions": "No restrictions", + "admin_user_freeze": "Freeze", + "admin_user_freeze_explain": "Temporarily block user's access to their account", + "admin_user_suspend": "Suspend", + "admin_user_suspend_explain": "Completely block the account, hide it from everyone, and delete it in 30 days", + "admin_user_foreign_suspend_explain": "Completely block this account from interacting with users and content on your server", + "admin_user_hide": "Hide", + "admin_user_hide_explain": "Make the profile only viewable by logged-in users", + "admin_user_ban_message": "Message", + "admin_user_ban_message_explain": "Will be shown to the user", + "admin_user_ban_duration": "Duration", + "admin_user_ban_until_first_login": "Until first login", + "admin_user_ban_force_password_change": "Force password change", + "admin_user_state_no_restrictions": "No restrictions applied.", + "admin_user_state_frozen": "Account is frozen until {expirationTime}.", + "admin_user_state_suspended": "Account is suspended and will be deleted {deletionTime}.", + "admin_user_state_suspended_foreign": "This remote user is suspended on your server.", + "admin_user_state_self_deactivated": "Account was deactivated by the user themselves and will be deleted {deletionTime}.", + "admin_user_state_hidden": "Profile is hidden from logged-out users.", + "admin_user_delete_account_now": "Delete right now", + "admin_user_delete_account_confirmation": "You are about to delete {name}''s account with immediate effect.\n\nThis is IRREVERSIBLE even if you have a database and media files backup. To proceed, enter below the complete username with domain for the account you''re deleting.", + "admin_user_delete_account_title": "Delete account", + "admin_audit_log_changed_user_restrictions": "{name} changed restrictions for {targetName}''s account", + "admin_user_delete_wrong_username": "Incorrect username", + "admin_user_deleted_successfully": "Account has been deleted", + "admin_audit_log_deleted_user_account": "{name} deleted {targetName}''s account", + "admin_report_details": "Report details", + "admin_report_title_X": "Report #{id}", + "admin_report_no_actions": "Action history and comments on this report will appear here", + "admin_report_content_post": "Post #{id}", + "admin_report_content_comment": "Comment #{id}", + "admin_report_content_message": "Message #{id}", + "admin_report_content": "Content", + "mark_report_unresolved": "Mark unresolved", + "report_action_delete_content": "Delete content...", + "report_action_delete_content_locally": "Delete content locally...", + "report_action_reject": "Reject", + "report_action_limit_user": "Limit user...", + "report_action_delete_and_limit": "Delete & limit...", + "report_state_open": "Open", + "report_state_resolved": "Resolved", + "report_state_rejected": "Rejected", + "report_log_rejected": "{name} rejected this report", + "report_log_reopened": "{name} marked this report unresolved", + "report_log_commented": "{name} left a comment:", + "report_content_created_at": "Created", + "report_confirm_delete_content": "Are you sure you want to delete the content attached to this report? This action can not be undone.", + "report_delete_content_title": "Content deletion", + "report_delete_content_checkbox": "Also delete content attached to this report", + "report_delete_content_checkbox_explanation": "This action can not be undone", + "report_log_deleted_content": "{name} deleted the content attached to this report", + "reports_of_user": "Of {name}", + "reports_by_user": "By {name}", + "admin_user_staff_notes_empty": "Add a note about this user for other members of your server staff or for your future self. These notes are only visible to those who have access to manage users.", + "admin_user_X_staff_notes_summary": "{count, plural, =0 {No staff notes} one {# staff note} other {# staff notes}} for this user", + "admin_user_staff_note_confirm_delete": "Are you sure you want to delete this note?", + "admin_email_domain_rules": "E-mail domains", + "admin_email_X_domain_rules_summary": "{count, plural, =0 {No email domain rules} one {# email domain rule} other {# email domain rules}}", + "admin_create_rule": "Create rule", + "admin_email_rule_reject": "Reject signups", + "admin_email_rule_review": "Send signups to manual review", + "admin_email_domain_rules_empty": "There are no rules for e-mail domains", + "admin_rule_note": "Note", + "admin_rule_note_explanation": "You can add a note to this rule for other members of your server staff or for your future self", + "admin_rule_action": "Action", + "admin_audit_log_created_email_rule": "{name} created a rule for e-mail domain {domain}:", + "admin_audit_log_updated_email_rule": "{name} updated a rule for e-mail domain {domain}:", + "admin_audit_log_deleted_email_rule": "{name} deleted a rule for e-mail domain {domain}:", + "err_admin_email_rule_already_exists": "A rule for this domain already exists", + "admin_email_rule_created": "Domain rule created", + "admin_email_rule_title": "E-mail domain rule", + "admin_confirm_delete_rule": "Are you sure you want to delete this rule?", + "admin_ip_rules": "IP addresses", + "admin_ip_rule_address": "IP or subnet", + "admin_ip_rule_expiry": "Expiry time", + "admin_ip_rule_expiry_explanation": "IP addresses are a limited resource and often change owners, so it is not recommended to block them indefinitely", + "admin_audit_log_created_ip_rule": "{name} created a rule for IP {addressOrSubnet}:", + "admin_audit_log_updated_ip_rule": "{name} updated a rule for IP {addressOrSubnet}:", + "admin_audit_log_deleted_ip_rule": "{name} deleted a rule for IP {addressOrSubnet}:", + "admin_X_ip_rules_summary": "{count, plural, =0 {No IP address rules} one {# IP address rule} other {# IP address rules}}", + "admin_ip_rule_until_time": "{action} until {time}", + "admin_ip_rules_empty": "There are no rules for IP addresses", + "admin_ip_rule_title": "IP or subnet rule", + "err_admin_ip_format_invalid": "Invalid IP address. Enter a single IPv4 or IPv6 address, or an address range in CIDR notation.", + "admin_ip_rule_created": "IP rule created", + "summary_admin_X_signup_invites": "{count, plural, =0 {No invitations} one {# invitation} other {# invitations}} total on your server", + "admin_audit_log_deleted_invite": "{name} deleted {targetName}''s invitation", + "signup_invite_deleted": "Invitation deleted" } \ No newline at end of file diff --git a/src/main/resources/langs/en/email.json b/src/main/resources/langs/en/email.json index 6c046254..b85ff1cb 100644 --- a/src/main/resources/langs/en/email.json +++ b/src/main/resources/langs/en/email.json @@ -21,5 +21,13 @@ "email_invite_body_end_plain": "Follow the link below to begin registration.", "email_invite_subject": "Invitation to join {serverName}", "email_invite_subject_approved": "Your request to join {serverName} was approved", - "begin_registration": "Begin registration" + "begin_registration": "Begin registration", + "email_account_frozen_subject": "{domain}: account frozen", + "email_account_frozen_body": "{name}, your account on {serverName} has been frozen until {date} for violating the server rules. Repeat violations may result in longer freezes and a permanent suspension.", + "email_account_suspended_subject": "{domain}: account suspended", + "email_account_suspended_body": "{name}, your account on {serverName} has been suspended for breaking the server rules and will be deleted {deletionDate}. You can still export your data, set up a redirect, and move your followers to another server.", + "email_confirmation_code": "{name}, use this code to confirm {action}:", + "email_confirmation_code_info": "Don't share this code with anyone. If you haven't requested it, change your password immediately and terminate any suspicious sessions.", + "email_confirmation_code_subject": "{domain}: action confirmation", + "email_confirm_action_unfreeze": "unfreezing your account" } \ No newline at end of file diff --git a/src/main/resources/langs/en/friends.json b/src/main/resources/langs/en/friends.json index 3f4088c4..4cc1fc5b 100644 --- a/src/main/resources/langs/en/friends.json +++ b/src/main/resources/langs/en/friends.json @@ -54,5 +54,7 @@ "friend_list_your_friends": "Your friends", "friends_in_list": "Friends in list", "select_friends_empty_selection": "You can choose friends in the list to the left.", - "select_friends_button": "Select friends" + "select_friends_button": "Select friends", + "X_followers": "{count, plural, =0 {No followers} one {# follower} other {# followers}}", + "X_following": "{count, plural, =0 {Not following anyone} one {# following} other {# following}}" } \ No newline at end of file diff --git a/src/main/resources/langs/en/global.json b/src/main/resources/langs/en/global.json index c0edde3a..a95d3e3f 100644 --- a/src/main/resources/langs/en/global.json +++ b/src/main/resources/langs/en/global.json @@ -75,7 +75,6 @@ "about_admins": "Administrators", "about_software": "This server runs {sw} version {version}.", "about_on_this_server": "On this server are", - "your_account_is_banned": "Your account has been banned by this server's administrators.", "show_previous_comments": "Show previous comments", "comments_show_X_replies": "Show {count, plural, one {# reply} other {# replies}}", "search": "Search", @@ -87,7 +86,6 @@ "search_groups": "Groups", "search_external_objects": "External objects", "nothing_found": "Nothing found", - "max_file_size_exceeded": "This file is larger than the maximum size of {size} MB.", "in_reply_to_X": "In reply to {name}", "in_reply_to_name": "{name}", "X_days": "{count, plural, one {# day} other {# days}}", @@ -132,5 +130,36 @@ "remote_object_loading_error": "Failed to load the remote object.", "err_access_user_content": "This user has chosen to hide this page.", "menu_mail": "My Messages", - "content_type_message": "Message" + "content_type_message": "Message", + "X_users": "{count, plural, =0 {No users} one {# user} other {# users}}", + "change": "Change", + "ip_address": "IP address", + "message_from_staff": "Message from staff", + "account_frozen": "Account is temporarily blocked", + "account_suspended": "Account is blocked", + "account_deactivated": "Account is deactivated", + "account_frozen_info": "{name}, we have temporarily frozen your account because you have violated the server rules.", + "account_frozen_info_time": "You will be able to unfreeze your account {time}.", + "account_ban_message": "This account was blocked by a moderator with the following comment:", + "account_frozen_unfreeze": "To unfreeze your account, you will need to confirm that you are its owner.", + "account_frozen_unfreeze_password": "To unfreeze your account, you will need to confirm that you are its owner and change your password.", + "unfreeze_my_account": "Unfreeze my account", + "account_suspended_info": "{name}, we have permanently suspended your account because you have repeatedly violated server rules. Your account will be deleted {deletionTime}. Until then, you are able to download your data or set up a redirection to another server and move your followers.", + "account_ban_contact": "If you have any questions, you can contact us.", + "account_deactivated_info": "You have deactivated your account. It will be permanently deleted {deletionTime}.", + "account_reactivate": "Reactivate my account", + "action_confirmation": "Action confirmation", + "action_confirmation_text": "We''ve sent you a code to {maskedEmail}. Please enter it to confirm {action}:", + "next": "Next", + "action_confirmation_incorrect_code": "Incorrect code", + "restore": "Restore", + "cw_default": "Sensitive content", + "err_file_upload": "The server was unable to store your file because of an internal error. Please try again later.", + "err_file_upload_image_format": "Your image file is damaged or of an unsupported format. Try converting it to JPEG or PNG.", + "err_file_upload_too_large": "Your file exceeds the maximum size of {maxSize}.", + "file_size_bytes": "{amount} bytes", + "file_size_kilobytes": "{amount} KB", + "file_size_megabytes": "{amount} MB", + "file_size_gigabytes": "{amount} GB", + "file_size_terabytes": "{amount} TB" } \ No newline at end of file diff --git a/src/main/resources/langs/en/profile.json b/src/main/resources/langs/en/profile.json index 328dac0e..428ea877 100644 --- a/src/main/resources/langs/en/profile.json +++ b/src/main/resources/langs/en/profile.json @@ -12,5 +12,9 @@ "incomplete_profile": "This profile might be incomplete.", "this_is_you": "(this is you)", "profile_moved": "{name} now uses another profile:", - "profile_moved_link": "{name} on {domain}" + "profile_moved_link": "{name} on {domain}", + "profile_deactivated": "This profile was deleted by its owner.", + "profile_hidden": "Hidden profile", + "profile_hidden_info": "You need to log in to view this profile.", + "profile_banned": "This account was banned by the server staff." } \ No newline at end of file diff --git a/src/main/resources/langs/en/settings.json b/src/main/resources/langs/en/settings.json index 155b0d12..b69e1581 100644 --- a/src/main/resources/langs/en/settings.json +++ b/src/main/resources/langs/en/settings.json @@ -149,5 +149,15 @@ "privacy_settings_value_except": ", excluding ", "privacy_settings_value_certain_friends_before": ": ", "privacy_settings_value_except_name": "{name}", - "privacy_settings_value_name_separator": ", " + "privacy_settings_value_name_separator": ", ", + "settings_sessions": "Activity history", + "settings_activity_access_type": "Access type", + "settings_activity_access_time": "Time", + "settings_activity_web": "{browserName} {browserVersion} on {osName}", + "unknown_browser": "Unknown browser", + "settings_deactivate_confirm": "Are you sure you want to delete your account?\n\nYour account will be deactivated and only permanently deleted after {timeInterval}. Should you change your mind, you will be able to reinstate your account with no data loss during the deactivation period.", + "settings_deactivate_account": "You can delete your account.", + "settings_reactivate_confirm": "Enter your password to reinstate your account and cancel its deletion.", + "settings_reactivate_title": "Restore account", + "err_reg_email_domain_not_allowed": "Signups with e-mail addresses from this provider are not allowed on this server" } \ No newline at end of file diff --git a/src/main/resources/langs/en/wall.json b/src/main/resources/langs/en/wall.json index c5e5f30c..b7155bfe 100644 --- a/src/main/resources/langs/en/wall.json +++ b/src/main/resources/langs/en/wall.json @@ -62,5 +62,6 @@ "graffiti_on_group_X_wall": "Graffiti on \"{name}\" wall", "post_visible_to_followers": "This post is only visible to {name}''s followers", "post_visible_to_followers_mentioned": "This post is only visible to {name}''s followers and mentioned people", - "post_visible_to_friends": "This post is only visible to {name}''s friends" + "post_visible_to_friends": "This post is only visible to {name}''s friends", + "expand_all_cws": "Expand all CWs" } \ No newline at end of file diff --git a/src/main/resources/langs/es/admin.json b/src/main/resources/langs/es/admin.json index d1aba0b3..2319763a 100644 --- a/src/main/resources/langs/es/admin.json +++ b/src/main/resources/langs/es/admin.json @@ -10,11 +10,8 @@ "invited_by": "Invitado por", "signup_date": "Fecha de registro", "actions": "Acciones", - "access_level": "Nivel de acceso", - "choose_access_level_for_X": "Elige el nivel de acceso para {name}", - "access_level_regular": "Usuario regular", - "access_level_moderator": "Moderador", - "access_level_admin": "Administrador", + "role": "Rol", + "choose_role_for_X": "Elegir rol para {name}", "admin_signup_mode": "Modo de registro", "admin_signup_mode_open": "Abierto", "admin_signup_mode_invite": "Sólo con invitación", @@ -34,11 +31,6 @@ "admin_server_short_description": "Descripción breve", "admin_server_policy": "Política", "admin_server_info_html_explain": "Puedes usar HTML en los campos de descripción y política.", - "admin_ban": "Banear", - "admin_unban": "Desbanear", - "admin_ban_X_confirm": "¿Estás seguro de que quieres banear la cuenta de {name}?", - "admin_unban_X_confirm": "¿Estás seguro de que quieres desbanear la cuenta de {name}?", - "ban_reason": "Motivo por el baneo", "sync_friends_and_groups": "Sincronizar amigos & grupos", "sync_members": "Sincronizar lista de miembros", "sync_content": "Sincronizar contenido", @@ -65,13 +57,7 @@ "report_comment": "Información adicional del usuario", "private_post_warning_title": "Se supone que no tienes acceso a esta publicación", "private_group_post_warning": "Esta publicación está en un grupo {groupType, select, private {privado} other {cerrado}} del cual no eres parte. Únicamente puedes verla porque la has abierto de un reporte.", - "mark_report_resolved": "Marcar como resuelto", - "report_action_delete_post": "Eliminar publicación", - "report_action_delete_post_locally": "Eliminar publicación localmente", - "report_action_delete_comment": "Eliminar comentario", - "report_action_delete_comment_locally": "Eliminar comentario localmente", "report_action_add_cw": "Añadir CW", - "report_action_suspend": "Suspender...", "admin_federation": "Federación", "search_server_domain": "Dominio del servidor", "summary_X_servers": "{count, plural, one {# servidor} other {# servidores}} en total", @@ -111,5 +97,161 @@ "admin_captcha_signup_form": "En el formulario de registro", "server_stats_activities_sent": "Actividades enviadas", "server_stats_activities_received": "Actividades recibidas", - "server_stats_delivery_errors": "Errores de envío" + "server_stats_delivery_errors": "Errores de envío", + "menu_users": "Usuarios", + "menu_access": "Federación y acceso", + "menu_stats": "Estadísticas", + "admin_invites": "Invitaciones", + "role_none": "(sin rol)", + "role_owner": "Propietario", + "role_admin": "Administrador", + "role_moderator": "Moderador", + "admin_server_settings": "Servidor", + "admin_roles": "Roles", + "admin_roles_summary": "{count, plural, one {# rol} other {# roles}}", + "admin_create_role": "Crear uno nuevo", + "admin_permission_superuser": "Superusuario", + "admin_permission_server_settings": "Administrar los ajustes del servidor", + "admin_permission_rules": "Administrar las reglas del servidor", + "admin_permission_roles": "Administrar los roles de usuario", + "admin_permission_audit_log": "Ver registro de auditoría", + "admin_permission_manage_users": "Administrar usuarios", + "admin_permission_user_access": "Administrar acceso de usuario", + "admin_permission_reports": "Administrar reportes", + "admin_permission_federation": "Administrar federación", + "admin_permission_blocking_rules": "Administrar las reglas de bloqueo", + "admin_permission_invites": "Administrar invitaciones", + "admin_permission_announcements": "Administrar anuncios", + "admin_permission_delete_users": "Eliminar las cuentas de usuario y datos", + "admin_permission_manage_groups": "Administrar grupos y eventos", + "admin_visible_in_staff": "Mostrados en el personal del servidor", + "admin_permission_descr_superuser": "Tiene todos los permisos y puede hacer todo", + "admin_permission_descr_server_settings": "Permite cambiar el nombre del servidor, la descripción, modo de registro y otros ajustes", + "admin_permission_descr_rules": "Permite editar, añadir y eliminar reglas del servidor", + "admin_permission_descr_roles": "Permite editar, crear y asignar roles de usuario iguales o inferiores a los suyos", + "admin_permission_descr_audit_log": "Permite ver el registro de todas las acciones administrativas realizadas en el servidor", + "admin_permission_descr_manage_users": "Permite ver los detalles de los usuarios y realizar acciones de moderación contra sus cuentas", + "admin_permission_descr_user_access": "Permite restablecer las contraseñas de los usuarios y cambiar sus correos electrónicos", + "admin_permission_descr_reports": "Permite ver y actuar en reportes", + "admin_permission_descr_federation": "Permite cambiar las restricciones de la federación en este servidor", + "admin_permission_descr_blocking_rules": "Permite bloquear proveedores de correo electrónico y direcciones IP", + "admin_permission_descr_invites": "Permite ver todos los enlaces de invitación activos en el servidor y desactivarlos", + "admin_permission_descr_announcements": "Permite crear y editar anuncios en este servidor", + "admin_permission_descr_delete_users": "Permite eliminar cuentas de usuario inmediatamente sin tener que esperar 30 días", + "admin_permission_descr_manage_groups": "Permite ver los detalles de grupos y realizar acciones de moderación contra ellos", + "admin_visible_in_staff_descr": "Mostrar usuarios con este rol bajo \"Administración\" en \"Acerca de este servidor\"", + "admin_delete_role": "Eliminar rol", + "admin_delete_role_confirm": "¿Estás seguro de que quieres eliminar este rol de todos los usuarios que lo tienen asignado y eliminarlo? Esta acción no puede deshacerse.", + "admin_role_name": "Nombre del rol", + "admin_permissions": "Permisos", + "admin_role_X_saved": "El rol \"{name}\" fue guardado", + "admin_role_X_created": "El rol \"{name}\" fue creado", + "admin_no_permissions_selected": "Selecciona por lo menos un permiso", + "admin_edit_role_title": "Editar rol", + "admin_create_role_title": "Crear rol", + "admin_audit_log": "Registro de auditoría", + "admin_audit_log_summary": "{count, plural, =0 {Ninguna entrada de registro} one {# entrada de registro en total} other {# entradas de registro en total}}", + "admin_audit_log_empty": "Los registros de las acciones administrativas aparecerán aquí", + "admin_audit_log_created_role": "{name} creó el rol \"{roleName}\"", + "admin_audit_log_edited_role": "{name} editó el rol \"{roleName}\"", + "admin_audit_log_deleted_role": "{name} eliminó el rol \"{roleName}\"", + "admin_audit_log_assigned_role": "{name} asignó el rol \"{roleName}\" a {targetName}", + "admin_audit_log_unassigned_role": "{name} eliminó el rol de {targetName}", + "admin_user_location_any": "Cualquier ubicación", + "admin_user_location_local": "Local", + "admin_user_location_remote": "Remoto", + "summary_X_users": "{count, plural, one {# usuario} other {# usuarios}} en total", + "summary_X_users_found": "{count, plural, =0 {0 usuarios} one {# usuario} other {# usuarios}} encontrados", + "admin_user_email_domain": "Dominio de correo", + "admin_user_ip_or_subnet": "Dirección IP o subred", + "any_role": "(cualquier)", + "search_users": "Buscar usuarios", + "no_users_found": "No se encontró ningún usuario que coincidiese con estos criterios", + "admin_last_user_activity": "Última vez activo", + "admin_manage_user": "Administrar usuario", + "admin_user_staff_notes": "Notas del personal", + "admin_account_id_tooltip": "ID de la cuenta local", + "admin_ap_actor_id_tooltip": "ID remota del actor de ActivitiHub", + "admin_account_activation_status": "Activación", + "admin_account_activated": "Correo confirmado", + "admin_account_email_unconfirmed": "Correo no confirmado", + "admin_account_email_change_pending": "El cambio de correo a {newEmail} fue solicitado, el nuevo correo no está confirmado", + "admin_others_with_this_domain": "Otros con este dominio", + "admin_actor_last_updated": "Última actualización", + "admin_end_session": "Finalizar sesión", + "admin_audit_log_activated_account": "{name} activó la cuenta de {targetName}", + "admin_audit_log_changed_email": "{name} cambió el correo de {targetName}", + "admin_audit_log_reset_password": "{name} reinició la contraseña de {targetName}", + "admin_audit_log_ended_session": "{name} terminó una de las sesiones de {targetName}", + "admin_session_terminated": "Sesión terminada", + "admin_user_restrictions": "Restricciones", + "admin_user_change_restrictions": "Cambiar las restricciones...", + "admin_ban_user_title": "Restricciones del usuario", + "admin_user_no_restrictions": "Sin restricciones", + "admin_user_freeze": "Congelar", + "admin_user_freeze_explain": "Bloquear temporalmente el acceso del usuario a su cuenta", + "admin_user_suspend": "Suspender", + "admin_user_suspend_explain": "Bloquear completamente la cuenta, ocultarla de todos y eliminarla en 30 días", + "admin_user_foreign_suspend_explain": "Bloquear completamente a esta cuenta de la interacción con otros usuarios y del contenido en tu servidor", + "admin_user_hide": "Ocultar", + "admin_user_hide_explain": "Hacer el perfil solo visible para usuarios registrados", + "admin_user_ban_message": "Mensaje", + "admin_user_ban_message_explain": "Será mostrado al usuario", + "admin_user_ban_duration": "Duración", + "admin_user_ban_until_first_login": "Hasta el primer inicio de sesión", + "admin_user_ban_force_password_change": "Forzar el cambio de contraseña", + "admin_user_state_no_restrictions": "No hay restricciones aplicadas.", + "admin_user_state_frozen": "La cuenta está congelada hasta {expirationTime}.", + "admin_user_state_suspended": "La cuenta está suspendida y será eliminada {deletionTime}.", + "admin_user_state_suspended_foreign": "Este usuario remoto está suspendido en tu servidor.", + "admin_user_state_self_deactivated": "La cuenta fue desactivada por el propio usuario y será eliminada {deletionTime}.", + "admin_user_state_hidden": "El perfil está oculto de los usuarios no registrados.", + "admin_user_delete_account_now": "Eliminar ahora", + "admin_user_delete_account_confirmation": "Estas a punto de eliminar la cuenta de {name} con efecto inmediato.\n\nEsto es IRREVERSIBLE incluso si tienes una base de datos y archivos multimedia como copia de seguridad. Para proceder, introduce abajo el nombre de usuario completo con dominio para la cuenta que estás eliminando.", + "admin_user_delete_account_title": "Eliminar cuenta", + "admin_audit_log_changed_user_restrictions": "{name} cambió las restricciones para la cuenta de {targetName}", + "admin_user_delete_wrong_username": "Nombre de usuario incorrecto", + "admin_user_deleted_successfully": "La cuenta ha sido eliminada", + "admin_audit_log_deleted_user_account": "{name} eliminó la cuenta de {targetName}", + "admin_report_details": "Detalles del reporte", + "admin_report_title_X": "Reporte #{id}", + "admin_report_no_actions": "El historial de acciones y comentarios en este reporte aparecerá aquí", + "admin_report_content_post": "Publicación #{id}", + "admin_report_content_comment": "Comentario #{id}", + "admin_report_content_message": "Mensaje #{id}", + "admin_report_content": "Contenido", + "mark_report_unresolved": "Marcar como no resuelto", + "report_action_delete_content": "Eliminar contenido...", + "report_action_delete_content_locally": "Eliminar contenido localmente...", + "report_action_reject": "Rechazar", + "report_action_limit_user": "Limitar usuario...", + "report_action_delete_and_limit": "Eliminar & límite...", + "report_state_open": "Abierto", + "report_state_resolved": "Resuelto", + "report_state_rejected": "Rechazado", + "report_log_rejected": "{name} rechazó este reporte", + "report_log_reopened": "{name} marcó este reporte como sin resolver", + "report_log_commented": "{name} dejó un comentario:", + "report_content_created_at": "Creado", + "report_confirm_delete_content": "¿Estás seguro de que quieres eliminar el contenido adjunto a este reporte? Esta acción no se puede deshacer.", + "report_delete_content_title": "Eliminación de contenido", + "report_delete_content_checkbox": "Eliminar también el contenido adjunto a este reporte", + "report_delete_content_checkbox_explanation": "Esta acción no se puede deshacer", + "report_log_deleted_content": "{name} eliminó el contenido adjunto a este reporte", + "reports_of_user": "De {name}", + "reports_by_user": "Por {name}", + "admin_user_staff_notes_empty": "Añadir una nota sobre este usuario para los otros miembros del personal de tu servidor o para tu futuro yo. Estas notas son únicamente visibles por aquellos que tienen acceso a la administración de usuarios.", + "admin_user_X_staff_notes_summary": "{count, plural, =0 {Cero notas del personal} one {# nota del personal} other {# notas del personal}} para este usuario", + "admin_user_staff_note_confirm_delete": "¿Estás seguro de que quieres eliminar esta nota?", + "admin_create_rule": "Crear regla", + "admin_email_rule_reject": "Rechazar registros", + "admin_email_rule_review": "Enviar los registros a revisión manual", + "admin_rule_note": "Nota", + "admin_rule_action": "Acción", + "err_admin_email_rule_already_exists": "Ya existe una regla para este dominio", + "admin_ip_rules": "Direcciones IP", + "admin_ip_rule_address": "IP o subred", + "admin_ip_rule_until_time": "{action} hasta {time}", + "admin_audit_log_deleted_invite": "{name} eliminó la invitación de {targetName}", + "signup_invite_deleted": "Invitación eliminada" } \ No newline at end of file diff --git a/src/main/resources/langs/es/email.json b/src/main/resources/langs/es/email.json index 0c0d6ba7..3201beeb 100644 --- a/src/main/resources/langs/es/email.json +++ b/src/main/resources/langs/es/email.json @@ -21,5 +21,13 @@ "email_invite_body_end_plain": "Sigue el enlace de abajo para comenzar el registro.", "email_invite_subject": "Invitación a unirse a {serverName}", "email_invite_subject_approved": "Tu solicitud a unirse a {serverName} fue aprobada", - "begin_registration": "Iniciar registro" + "begin_registration": "Iniciar registro", + "email_account_frozen_subject": "{domain}: cuenta congelada", + "email_account_frozen_body": "{name}, tu cuenta en {serverName} ha sido congelada hasta {date} por violar las reglas del servidor. Las violaciones repetidas podrían resultar en congelaciones más extensas y una suspensión permanente.", + "email_account_suspended_subject": "{domain}: cuenta suspendida", + "email_account_suspended_body": "{name}, tu cuenta en {serverName} ha sido suspendida por romper las reglas del servidor y será eliminada {deletionDate}. Aún puedes exportar tus datos, configurar una redirección y mover tus seguidores a otro servidor.", + "email_confirmation_code": "{name}, usa este código para confirmar {action}:", + "email_confirmation_code_info": "No compartas este código con alguien. Si no lo has pedido, cambia tu contraseña inmediatamente y termina cualquier sesión sospechosa.", + "email_confirmation_code_subject": "{domain}: confirmación de la acción", + "email_confirm_action_unfreeze": "descongelando tu cuenta" } \ No newline at end of file diff --git a/src/main/resources/langs/es/friends.json b/src/main/resources/langs/es/friends.json index fecb5726..f8a7d5fc 100644 --- a/src/main/resources/langs/es/friends.json +++ b/src/main/resources/langs/es/friends.json @@ -24,9 +24,9 @@ "external_X_send_friend_req_to_X": "{ownName}, estás a punto de enviar una solicitud de amistad a {name} en {domain}.", "mutual_friends": "Amigos mutuos", "X_mutual_friends": "{count, plural, one {# amigo mutuo} other {# amigos mutuos}}", - "X_mutual_friends_short": "{count, plural, one {# mutuo} other {# mutuos}}", + "X_mutual_friends_short": "{count} mutuos", "confirm_unfollow_X": "¿Estás seguro de que quieres dejar de seguir a {name}?", - "user_friends": "{name} de los amigos", + "user_friends": "Amigos de {name}", "summary_own_X_friends": "Tienes {numFriends, plural, =0 {0 amigos} one {# amigo} other {# amigos}}", "summary_user_X_friends": "{name} tiene {numFriends, plural, =0 {0 amigos} one {# amigo} other {# amigos}}", "summary_X_friend_requests": "{numRequests, plural, one {# persona te envió una petición de amistad} other {# personas te enviaron una petición de amistad}}", @@ -54,5 +54,7 @@ "friend_list_your_friends": "Tus amigos", "friends_in_list": "Amigos en la lista", "select_friends_empty_selection": "Puedes elegir amigos en la lista de la izquierda.", - "select_friends_button": "Seleccionar amigos" + "select_friends_button": "Seleccionar amigos", + "X_followers": "{count, plural, =0 {0 seguidores} one {# seguidor} other {# seguidores}}", + "X_following": "{count, plural, =0 {Siguiendo a 0} one {Siguiendo a #} other {Siguiendo a #}}" } \ No newline at end of file diff --git a/src/main/resources/langs/es/global.json b/src/main/resources/langs/es/global.json index 4a47ada4..edd9aa0d 100644 --- a/src/main/resources/langs/es/global.json +++ b/src/main/resources/langs/es/global.json @@ -70,8 +70,7 @@ "about_contact": "Contacto", "about_admins": "Administradores", "about_software": "Este servidor ejecuta {sw} versión {version}.", - "about_on_this_server": "En este servidor estás", - "your_account_is_banned": "Tu cuenta ha sido baneada por los administradores de este servidor.", + "about_on_this_server": "En este servidor hay", "show_previous_comments": "Mostrar comentarios anteriores", "comments_show_X_replies": "Mostrar {count, plural, one {# respuesta} other {# respuestas}}", "search": "Buscar", @@ -83,7 +82,6 @@ "search_groups": "Grupos", "search_external_objects": "Objetos externos", "nothing_found": "Nada encontrado", - "max_file_size_exceeded": "Este archivo es mayor que el tamaño máximo de {size} MB.", "in_reply_to_X": "En respuesta a {name}", "in_reply_to_name": "{name}", "X_days": "{count, plural, one {# día} other {# días}}", @@ -128,5 +126,36 @@ "remote_object_loading_error": "Error al cargar el objeto remoto.", "err_access_user_content": "Este usuario ha elegido ocultar esta página.", "menu_mail": "Mis mensajes", - "content_type_message": "Mensaje" + "content_type_message": "Mensaje", + "X_users": "{count, plural, =0 {0 usuarios} one {# usuario} other {# usuarios}}", + "change": "Cambiar", + "ip_address": "Dirección IP", + "message_from_staff": "Mensaje del personal", + "account_frozen": "La cuenta está bloqueada temporalmente", + "account_suspended": "La cuenta está bloqueada", + "account_deactivated": "La cuenta está desactivada", + "account_frozen_info": "{name}, hemos congelado temporalmente tu cuenta porque has violado las reglas del servidor.", + "account_frozen_info_time": "Podrás descongelar tu cuenta {time}.", + "account_ban_message": "Esta cuenta fue bloqueada por un moderador con el siguiente comentario:", + "account_frozen_unfreeze": "Para descongelar tu cuenta, necesitarás confirmar que eres su propietario.", + "account_frozen_unfreeze_password": "Para descongelar tu cuenta, necesitarás confirmar que eres su propietario y cambiar tu contraseña.", + "unfreeze_my_account": "Descongelar mi cuenta", + "account_suspended_info": "{name}, hemos suspendido permanentemente tu cuenta porque has violado repetidamente las reglas del servidor. Tu cuenta será eliminada {deletionTime}. Hasta entonces, puedes descargar tus datos o configurar una redirección a otro servidor y mover tus seguidores.", + "account_ban_contact": "Si tienes alguna pregunta, puedes contactarnos.", + "account_deactivated_info": "Has desactivado tu cuenta. Será eliminada permanentemente {deletionTime}.", + "account_reactivate": "Reactivar mi cuenta", + "action_confirmation": "Confirmación de la acción", + "action_confirmation_text": "Te hemos enviado un código a {maskedEmail}. Por favor, introdúcelo para confirmar {action}:", + "next": "Siguiente", + "action_confirmation_incorrect_code": "Código incorrecto", + "restore": "Restaurar", + "cw_default": "Contenido sensible", + "err_file_upload": "Este servidor no pudo almacenar tu archivo debido a un error interno. Por favor, inténtalo de nuevo más tarde.", + "err_file_upload_image_format": "Tu archivo de imagen está dañado o es de un formato no compatible. Prueba a convertirlo a JPEG o PNG.", + "err_file_upload_too_large": "Tu archivo excede el tamaño máximo de {maxSize}.", + "file_size_bytes": "{amount} bytes", + "file_size_kilobytes": "{amount} KB", + "file_size_megabytes": "{amount} MB", + "file_size_gigabytes": "{amount} GB", + "file_size_terabytes": "{amount} TB" } \ No newline at end of file diff --git a/src/main/resources/langs/es/profile.json b/src/main/resources/langs/es/profile.json index 160d3090..c9292a38 100644 --- a/src/main/resources/langs/es/profile.json +++ b/src/main/resources/langs/es/profile.json @@ -10,5 +10,11 @@ "X_is_following_you": "{name} te sigue", "waiting_for_X_to_accept_follow_req": "Estás esperando que {name} acepte tu solicitud de seguimiento", "incomplete_profile": "Este perfil puede estar incompleto.", - "this_is_you": "(este eres tú)" + "this_is_you": "(este eres tú)", + "profile_moved": "{name} ahora usa otro perfil:", + "profile_moved_link": "{name} en {domain}", + "profile_deactivated": "Este perfil fue eliminado por su propietario.", + "profile_hidden": "Perfil oculto", + "profile_hidden_info": "Necesitas registrarte para ver este perfil.", + "profile_banned": "Esta cuenta fue prohibida por el personal del servidor." } \ No newline at end of file diff --git a/src/main/resources/langs/es/reporting.json b/src/main/resources/langs/es/reporting.json index 4a4860e7..11563e73 100644 --- a/src/main/resources/langs/es/reporting.json +++ b/src/main/resources/langs/es/reporting.json @@ -16,5 +16,5 @@ "report_forward_to_domain": "Reenviar anónimamente a {domain}", "report_submitted": "Tu reporte fue enviado al personal del servidor.", "report_title_message": "Reportar un mensaje", - "report_text_message": "¿Cuál es el problema con este mensaje?" + "report_text_message": "¿Qué ocurre con este mensaje?" } \ No newline at end of file diff --git a/src/main/resources/langs/es/settings.json b/src/main/resources/langs/es/settings.json index 270cbc20..dff8ad59 100644 --- a/src/main/resources/langs/es/settings.json +++ b/src/main/resources/langs/es/settings.json @@ -117,7 +117,7 @@ "no_invited_users": "Nadie se inscribió usando tus invitaciones", "invited_people_title": "La gente a la que has invitado", "request_invitation": "Solicitar una invitación", - "request_invitation_reason": "Porque te gustaría unirte a nosotros", + "request_invitation_reason": "Por qué te gustaría unirte a nosotros", "request_invitation_reason_explain": "Esto nos ayudará a revisar tu aplicación.", "manual_signup_approval_explain": "Las inscripciones en este servidor están cerradas, pero puedes solicitar una invitación. Si el personal del servidor aprueba tu solicitud, recibirás una invitación en el email que especifiques aquí.", "signup_title": "Inscribirse", @@ -149,5 +149,15 @@ "privacy_settings_value_except": ", excluyendo ", "privacy_settings_value_certain_friends_before": ": ", "privacy_settings_value_except_name": "{name}", - "privacy_settings_value_name_separator": ", " + "privacy_settings_value_name_separator": ", ", + "settings_sessions": "Historial de actividad", + "settings_activity_access_type": "Tipo de acceso", + "settings_activity_access_time": "Hora", + "settings_activity_web": "{browserName} {browserVersion} en {osName}", + "unknown_browser": "Navegador desconocido", + "settings_deactivate_confirm": "¿Estás seguro de que quieres eliminar tu cuenta?\n\nTu cuenta será desactivada y únicamente eliminada después de {timeInterval}. Si cambias de opinión, podrás restablecer tu cuenta sin pérdida de datos durante el periodo de desactivación.", + "settings_deactivate_account": "Puedes eliminar tu cuenta.", + "settings_reactivate_confirm": "Introduce tu contraseña para restablecer tu cuenta y cancelar su eliminación.", + "settings_reactivate_title": "Restaurar cuenta", + "err_reg_email_domain_not_allowed": "Los registros con direcciones de correo electrónico de este proveedor no están permitidos en este servidor" } \ No newline at end of file diff --git a/src/main/resources/langs/es/wall.json b/src/main/resources/langs/es/wall.json index 47b72c66..000fe22e 100644 --- a/src/main/resources/langs/es/wall.json +++ b/src/main/resources/langs/es/wall.json @@ -62,5 +62,6 @@ "graffiti_on_group_X_wall": "Grafiti en el muro de \"{name}\"", "post_visible_to_followers": "Esta publicación solo es visible para los seguidores de {name}", "post_visible_to_followers_mentioned": "Esta publicación solo es visible para los seguidores de {name} y para personas mencionadas", - "post_visible_to_friends": "Esta publicación solo es visible para los amigos de {name}" + "post_visible_to_friends": "Esta publicación solo es visible para los amigos de {name}", + "expand_all_cws": "Expandir todos los CW" } \ No newline at end of file diff --git a/src/main/resources/langs/fr/admin.json b/src/main/resources/langs/fr/admin.json new file mode 100644 index 00000000..47a181b9 --- /dev/null +++ b/src/main/resources/langs/fr/admin.json @@ -0,0 +1,25 @@ +{ + "menu_admin": "Panneau d'administration", + "admin_server_info": "Informations sur le serveur", + "admin_server_name": "Nom", + "admin_server_description": "Description", + "admin_server_admin_email": "E-mail de l'administrateur", + "admin_server_info_updated": "Informations sur le serveur mises à jour", + "admin_users": "Utilisateurs", + "admin_user_id": "ID de compte (ID utilisateur)", + "invited_by": "Inviter par", + "signup_date": "Date d'inscription", + "actions": "Actions", + "role": "Rôle", + "choose_role_for_X": "Choisissez le rôle pour {name}", + "admin_signup_mode": "Mode d'inscription", + "admin_signup_mode_open": "Ouvrir", + "admin_signup_mode_invite": "Inviter seulement", + "admin_signup_mode_approval": "Par application (avec approbation manuelle par les administrateurs)", + "admin_signup_mode_closed": "Inscriptions clôturées", + "admin_signup_mode_explain": "Lorsque les inscriptions sont fermées, les administrateurs peuvent toujours inviter de nouveaux utilisateurs.", + "admin_other": "Autre", + "admin_email_settings": "Paramètres de messagerie", + "admin_email_from": "Adresse sortante", + "admin_email_smtp_server": "Serveur SMTP" +} \ No newline at end of file diff --git a/src/main/resources/langs/fr/email.json b/src/main/resources/langs/fr/email.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/email.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/feed.json b/src/main/resources/langs/fr/feed.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/feed.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/friends.json b/src/main/resources/langs/fr/friends.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/friends.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/global.json b/src/main/resources/langs/fr/global.json new file mode 100644 index 00000000..3a90675e --- /dev/null +++ b/src/main/resources/langs/fr/global.json @@ -0,0 +1,3 @@ +{ + "lang_name": "Français" +} \ No newline at end of file diff --git a/src/main/resources/langs/fr/groups.json b/src/main/resources/langs/fr/groups.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/groups.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/mail.json b/src/main/resources/langs/fr/mail.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/mail.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/notifications.json b/src/main/resources/langs/fr/notifications.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/notifications.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/profile.json b/src/main/resources/langs/fr/profile.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/profile.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/reporting.json b/src/main/resources/langs/fr/reporting.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/reporting.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/settings.json b/src/main/resources/langs/fr/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/fr/wall.json b/src/main/resources/langs/fr/wall.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/fr/wall.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/index.json b/src/main/resources/langs/index.json index 44ca54cb..6011fa01 100644 --- a/src/main/resources/langs/index.json +++ b/src/main/resources/langs/index.json @@ -43,6 +43,16 @@ "locale": "de", "name": "German", "fallback": "en" + }, + { + "locale": "nl", + "name": "Dutch", + "fallback": "en" + }, + { + "locale": "fr", + "name": "French", + "fallback": "en" } ] } diff --git a/src/main/resources/langs/nl/admin.json b/src/main/resources/langs/nl/admin.json new file mode 100644 index 00000000..f6f66f9c --- /dev/null +++ b/src/main/resources/langs/nl/admin.json @@ -0,0 +1,216 @@ +{ + "menu_admin": "Administratie Paneel", + "admin_server_info": "Server info", + "admin_server_name": "Naam", + "admin_server_description": "Beschrijving", + "admin_server_admin_email": "E-mailadres van beheerder", + "admin_server_info_updated": "Serverinformatie bijgewerkt", + "admin_users": "Gebruikers", + "admin_user_id": "Account-ID (user ID)", + "invited_by": "Uitgenodigd door", + "signup_date": "Datum van aanmelding", + "actions": "Acties", + "role": "Functie", + "choose_role_for_X": "Kies een rol voor {name}", + "admin_signup_mode": "Aanmeldmodus", + "admin_signup_mode_open": "Open", + "admin_signup_mode_invite": "Alleen op uitnodiging", + "admin_signup_mode_approval": "Op aanvraag (met handmatige goedkeuring door beheerders)", + "admin_signup_mode_closed": "Inschrijvingen gesloten", + "admin_signup_mode_explain": "Wanneer aanmeldingen zijn gesloten, kunnen beheerders nog steeds nieuwe gebruikers uitnodigen.", + "admin_other": "Ander", + "admin_email_settings": "E-mailinstellingen", + "admin_email_from": "Uitgaand adres", + "admin_email_smtp_server": "SMTP-server", + "admin_email_smtp_port": "Poort", + "admin_email_auth_explain": "Laat deze velden leeg als er geen authenticatie nodig is.", + "admin_email_smtp_use_tls": "Gebruik encryptie", + "admin_email_send_test": "Stuur een testmail", + "admin_email_test_sent": "Er is een test-e-mail verzonden", + "admin_email_test_address": "Adres", + "admin_server_short_description": "Korte beschrijving", + "admin_server_policy": "Beleid", + "admin_server_info_html_explain": "U kunt HTML gebruiken in de beschrijvings- en beleidsvelden.", + "sync_friends_and_groups": "Synchroniseer vrienden & groepen", + "sync_members": "Synchroniseer de ledenlijst", + "sync_content": "Synchroniseer inhoud", + "sync_profile": "Profiel synchroniseren", + "sync_started": "De synchronisatie is gestart en wordt op de achtergrond uitgevoerd.", + "admin_require_email_confirm": "Bevestigde e-mail vereisen voor nieuwe accounts", + "admin_activate_account": "Activeren", + "admin_activate_X_confirm": "Weet je zeker dat je het account van {name} wilt activeren?", + "menu_signup_requests": "Aanmeldingsverzoeken", + "summary_X_signup_requests": "{count, plural, =0 {Geen aanvragen} one {# aanvraag} other {# aanvragen}} om in te schrijven", + "no_signup_requests": "Er zijn geen ongezien aanmeldingsverzoeken.", + "signup_requests_title": "Aanmeldingsverzoeken", + "signup_request_sent_at": "Verstuur", + "delete_signup_request": "Verwijder verzoek", + "signup_request_deleted": "Verzoek verwijderd", + "menu_reports": "Rapporten", + "reports_tab_open": "Onopgelost", + "reports_tab_resolved": "Opgelost", + "summary_X_reports": "{count, plural, one {# rapport} other {# rapporten}} totaal", + "no_reports": "Er zijn geen rapporten", + "report_from": "Van", + "report_sender_anonymous": "anoniem", + "report_sent_at": "Verstuurd", + "report_comment": "Aanvullende informatie van gebruiker", + "private_post_warning_title": "Het is niet de bedoeling dat je toegang hebt tot dit bericht", + "private_group_post_warning": "Dit bericht staat in een {groupType, select, private {privé} other {besloten}} groep waarvan u geen lid bent. U kunt het alleen zien omdat u het vanuit een rapport hebt geopend.", + "report_action_add_cw": "CW toevoegen", + "admin_federation": "Federatie", + "search_server_domain": "Serverdomein", + "summary_X_servers": "{count, plural, one {# server} other {# servers}} totaal", + "summary_X_servers_found": "{count, plural, one {# server} other {# servers}} gevonden", + "server_state_not_restricted": "Niet beperkt", + "server_state_suspended": "Opgeschort", + "server_filter_all": "Alle", + "server_filter_restricted": "Beperkt", + "server_filter_any_availability": "Eventuele beschikbaarheid", + "server_filter_failing": "Bij gebreke", + "server_filter_unavailable": "Niet beschikbaar", + "no_servers": "Er zijn geen servers om weer te geven", + "server_restrictions": "Beperkingen", + "server_restrictions_none": "Deze server is op geen enkele manier beperkt.", + "server_restrictions_suspended": "Federatie met deze server is opgeschort. De gebruikers kunnen niet met die van u communiceren en omgekeerd.", + "server_restrictions_change": "Beperkingen bewerken...", + "server_availability": "Beschikbaarheid", + "server_availability_explain": "Als de levering van activiteiten aan deze server op zeven verschillende dagen, mislukt, wordt deze gemarkeerd als niet beschikbaar en wordt de federatie onderbroken. De federatie wordt automatisch hervat na ontvangst van activiteiten van deze server.", + "server_availability_up": "Server is beschikbaar.", + "server_availability_failing": "De laatste pogingen om activiteiten af te leveren op deze server zijn mislukt. Als de bezorging {days, plural, =1 {één dag meer} one {# dag} other {# verschillende dagen}} mislukt, wordt deze server gemarkeerd als niet beschikbaar. De laatste fout vond plaats op {lastErrorDate}.", + "server_availability_down": "Server is niet beschikbaar. Federatie is onderbroken.", + "server_reset_availability": "Markeer server als beschikbaar en hervat federatie", + "federation_restriction_public_comment": "Openbare opmerking", + "federation_restriction_public_comment_explain": "Zal voor iedereen zichtbaar zijn op de pagina 'Over deze server'.", + "federation_restriction_private_comment": "Privé commentaar", + "federation_restriction_private_comment_explain": "Zal alleen zichtbaar zijn voor uw toekomstige zelf en andere moderators.", + "federation_restriction_title": "Serverbeperkingen", + "federation_no_restrictions": "Onbeperkt", + "federation_suspend": "Federatie schorsing", + "federation_suspend_explain": "De gebruikers van deze server kunnen niet met die van u communiceren en omgekeerd.", + "about_server_federation_restrictions": "Federatiebeperkingen", + "about_server_federation_restrictions_explain": "Het personeel van deze server heeft de federatie beperkt met {count, plural, one {# andere server} other {# andere servers}}:", + "federation_restriction_reason": "Reden", + "post_deleted_placeholder": "Bericht is verwijderd", + "report_resolved_at": "Opgelost", + "admin_enable_captcha": "Gebruik captcha", + "admin_captcha_signup_form": "In het aanmeldingsformulier", + "server_stats_activities_sent": "Activiteiten verzonden", + "server_stats_activities_received": "Activiteiten ontvangen", + "server_stats_delivery_errors": "Leveringsfouten", + "menu_users": "Gebruikers", + "menu_access": "Federatie & Toegang", + "menu_stats": "Statistieken", + "admin_invites": "Uitnodigingen", + "role_none": "(geen rol)", + "role_owner": "Eigenaar", + "role_admin": "Beheerder", + "role_moderator": "Moderator", + "admin_server_settings": "Server", + "admin_roles": "Rollen", + "admin_roles_summary": "{count, plural, one {# rol} other {# rollen}}", + "admin_create_role": "Maak een nieuwe", + "admin_permission_superuser": "Super gebruiker", + "admin_permission_server_settings": "Beheer serverinstellingen", + "admin_permission_rules": "Beheer serverregels", + "admin_permission_roles": "Beheer gebruikersrollen", + "admin_permission_audit_log": "Bekijk auditlogboek", + "admin_permission_manage_users": "Gebruikers beheren", + "admin_permission_user_access": "Beheer gebruikerstoegang", + "admin_permission_reports": "Beheer rapporten", + "admin_permission_federation": "Federatie beheren", + "admin_permission_blocking_rules": "Beheer blokkeerregels", + "admin_permission_invites": "Beheer uitnodigingen", + "admin_permission_announcements": "Beheer aankondigingen", + "admin_permission_delete_users": "Verwijder gebruikersaccounts en gegevens", + "admin_permission_manage_groups": "Beheer groepen en evenementen", + "admin_visible_in_staff": "Weergegeven in serverpersoneel", + "admin_permission_descr_superuser": "Heeft alle rechten en kan alles", + "admin_permission_descr_server_settings": "Maakt het mogelijk de servernaam, beschrijving, aanmeldingsmodus en andere instellingen te wijzigen", + "admin_permission_descr_rules": "Maakt het bewerken, toevoegen en verwijderen van serverregels mogelijk", + "admin_permission_descr_roles": "Maakt het bewerken, creëren en toewijzen van gebruikersrollen mogelijk die gelijk of lager zijn dan die van hen", + "admin_permission_descr_audit_log": "Maakt het mogelijk het logboek te bekijken van alle administratieve acties die op de server zijn uitgevoerd", + "admin_permission_descr_manage_users": "Maakt het mogelijk de gegevens van gebruikers te bekijken en moderatieacties uit te voeren op hun accounts", + "admin_permission_descr_user_access": "Maakt het mogelijk de wachtwoorden van gebruikers opnieuw in te stellen en hun e-mails te wijzigen", + "admin_permission_descr_reports": "Maakt het bekijken van en reageren op rapporten mogelijk", + "admin_permission_descr_federation": "Maakt het mogelijk om de federatiebeperkingen van deze server te wijzigen", + "admin_permission_descr_blocking_rules": "Maakt het blokkeren van e-mailproviders en IP-adressen mogelijk", + "admin_permission_descr_invites": "Maakt het mogelijk om alle actieve uitnodigingslinks op de server te bekijken en deze te deactiveren", + "admin_permission_descr_announcements": "Maakt het maken en bewerken van aankondigingen op deze server mogelijk", + "admin_permission_descr_delete_users": "Maakt het mogelijk gebruikersaccounts onmiddellijk te verwijderen zonder 30 dagen te hoeven wachten", + "admin_permission_descr_manage_groups": "Maakt het mogelijk details van groepen te bekijken en moderatieacties tegen hen uit te voeren", + "admin_visible_in_staff_descr": "Toon gebruikers met deze rol onder 'Beheer' in 'Over deze server'", + "admin_delete_role": "Rol verwijderen", + "admin_delete_role_confirm": "Weet u zeker dat u deze rol wilt verwijderen van alle gebruikers aan wie deze is toegewezen? Deze actie kan niet ongedaan gemaakt worden.", + "admin_role_name": "Rol naam", + "admin_permissions": "Rechten", + "admin_role_X_saved": "Rol \"{name}\" is opgeslagen", + "admin_role_X_created": "Rol \"{name}\" is gemaakt", + "admin_no_permissions_selected": "Selecteer ten minste één machtiging", + "admin_edit_role_title": "Rol bewerken", + "admin_create_role_title": "Rol creëren", + "admin_audit_log": "Auditlogboek", + "admin_audit_log_summary": "{count, plural, =0 {Geen logvermeldingen} one {# logboekinvoer totaal} other {# logvermeldingen in totaal}}", + "admin_audit_log_empty": "Hier verschijnen verslagen van administratieve handelingen", + "admin_audit_log_created_role": "{name} heeft rol \"{roleName}\" gemaakt", + "admin_audit_log_edited_role": "{name} heeft rol \"{roleName}\" bewerkt", + "admin_audit_log_deleted_role": "{name} heeft rol \"{roleName}\" verwijderd", + "admin_audit_log_assigned_role": "{name} heeft rol \"{roleName}\" toegewezen aan {targetName}", + "admin_audit_log_unassigned_role": "{name} heeft de rol van {targetName} verwijderd", + "admin_user_location_any": "Elke locatie", + "admin_user_location_local": "Lokaal", + "admin_user_location_remote": "Op afstand", + "summary_X_users": "{count, plural, one {# gebruiker} other {# gebruikers}} totaal", + "summary_X_users_found": "{count, plural, =0 {Geen gebruikers} one {# gebruiker} other {# gebruikers}} gevonden", + "admin_user_email_domain": "E-maildomein", + "admin_user_ip_or_subnet": "IP-adres of subnet", + "any_role": "(elk)", + "search_users": "Zoek gebruikers", + "no_users_found": "Er zijn geen gebruikers gevonden die aan deze criteria voldoen", + "admin_last_user_activity": "Laatst actief", + "admin_manage_user": "Gebruiker beheren", + "admin_user_staff_notes": "Notities van het personeel", + "admin_account_id_tooltip": "Lokale account-ID", + "admin_ap_actor_id_tooltip": "Acteur-ID voor externe ActivityPub", + "admin_account_activation_status": "Activering", + "admin_account_activated": "E-mail bevestigd", + "admin_account_email_unconfirmed": "E-mailadres niet bevestigd", + "admin_account_email_change_pending": "E-mailwijziging naar {newEmail} is aangevraagd, nieuwe e-mail niet bevestigd", + "admin_others_with_this_domain": "Anderen met dit domein", + "admin_actor_last_updated": "Laatst bijgewerkt", + "admin_end_session": "Sessie beëindigen", + "admin_audit_log_activated_account": "{name} heeft het account van {targetName} geactiveerd", + "admin_audit_log_changed_email": "{name} heeft het e-mailadres van {targetName} gewijzigd", + "admin_audit_log_reset_password": "{name} heeft het wachtwoord van {targetName} opnieuw ingesteld", + "admin_audit_log_ended_session": "{name} heeft een van de sessies van {targetName} beëindigd", + "admin_session_terminated": "Sessie beëindigd", + "admin_user_restrictions": "Beperkingen", + "admin_user_change_restrictions": "Beperkingen wijzigen...", + "admin_ban_user_title": "Gebruikersbeperkingen", + "admin_user_no_restrictions": "Geen beperkingen", + "admin_user_freeze": "Bevriezen", + "admin_user_freeze_explain": "Blokkeer tijdelijk de toegang van gebruikers tot hun account", + "admin_user_suspend": "Opschorten", + "admin_user_suspend_explain": "Blokkeer het account volledig, verberg het voor iedereen en verwijder het binnen 30 dagen", + "admin_user_foreign_suspend_explain": "Blokkeer de interactie van dit account met gebruikers en inhoud op uw server volledig", + "admin_user_hide": "Verbergen", + "admin_user_hide_explain": "Maak het profiel alleen zichtbaar voor ingelogde gebruikers", + "admin_user_ban_message": "Bericht", + "admin_user_ban_message_explain": "Wordt aan de gebruiker getoond", + "admin_user_ban_duration": "Duur", + "admin_user_ban_until_first_login": "Tot de eerste keer inloggen", + "admin_user_ban_force_password_change": "Wachtwoordwijziging forceren", + "admin_user_state_no_restrictions": "Er zijn geen beperkingen toegepast.", + "admin_user_state_frozen": "Account is bevroren tot {expirationTime}.", + "admin_user_state_suspended": "Account is opgeschort en zal {deletionTime} worden verwijderd.", + "admin_user_state_suspended_foreign": "Deze externe gebruiker is opgeschort op uw server.", + "admin_user_state_self_deactivated": "Het account is door de gebruiker zelf gedeactiveerd en wordt {deletionTime} verwijderd.", + "admin_user_state_hidden": "Profiel is verborgen voor uitgelogde gebruikers.", + "admin_user_delete_account_now": "Verwijder nu meteen", + "admin_user_delete_account_confirmation": "U staat op het punt het account van {name} met onmiddellijke ingang te verwijderen.\n\nDit is ONOMKEERBAAR, zelfs als u een back-up van de database en mediabestanden heeft. Om verder te gaan, voert u hieronder de volledige gebruikersnaam met domein in voor het account dat u verwijdert.", + "admin_user_delete_account_title": "Account verwijderen", + "admin_audit_log_changed_user_restrictions": "{name} heeft de beperkingen gewijzigd voor het account van {targetName}", + "admin_user_delete_wrong_username": "Foute gebruikersnaam", + "admin_user_deleted_successfully": "Account is verwijderd", + "admin_audit_log_deleted_user_account": "{name} heeft het account van {targetName} verwijderd" +} \ No newline at end of file diff --git a/src/main/resources/langs/nl/email.json b/src/main/resources/langs/nl/email.json new file mode 100644 index 00000000..1cd13622 --- /dev/null +++ b/src/main/resources/langs/nl/email.json @@ -0,0 +1,33 @@ +{ + "email_password_reset_subject": "{domain} wachtwoord reset", + "email_password_reset_html_before": "{name}, klik op de onderstaande knop om het wachtwoord voor uw account opnieuw in te stellen op {serverName}. Deze link is 24 uur geldig.", + "email_password_reset_plain_before": "{name}, volg de onderstaande link om het wachtwoord voor uw account opnieuw in te stellen op {serverName}. Deze link is 24 uur geldig.", + "email_password_reset_after": "Als u geen wachtwoordverzoek heeft aangevraagd, kunt u deze e-mail negeren. Je account is veilig.", + "email_test_subject": "Test-e-mail", + "email_test": "Als u dit leest, heeft u uw server waarschijnlijk correct geconfigureerd.", + "email_confirmation_subject": "Welkom bij {domain}!", + "email_confirmation_body_html": "{name}, bedankt voor het aanmelden voor een account op {serverName}. Klik op onderstaande knop om uw account te activeren.", + "email_confirmation_body_plain": "{name}, bedankt voor het aanmelden voor een account op {serverName}. Volg de onderstaande link om uw account te activeren.", + "email_confirmation_body_button": "Activeer mijn account", + "email_change_old_subject": "Uw e-mailadres op {domain} is geupdated", + "email_change_old_body": "{name}, het e-mailadres in uw {serverName}-account is succesvol gewijzigd in {address}. Als u het was, negeer dan deze e-mail. Anders kunt u zich aanmelden en uw wachtwoord onmiddellijk wijzigen om uw account te beveiligen.", + "email_change_new_subject": "Bevestig je nieuwe e-mailadres voor {domain}", + "email_change_new_body_html": "{name}, klik op de onderstaande knop om het wijzigen van het e-mailadres in uw account van {oldAddress} in {newAddress} te voltooien.", + "email_change_new_body_plain": "{name}, volg de onderstaande link om het wijzigen van het e-mailadres in uw account van {oldAddress} in {newAddress} te voltooien.", + "email_change_new_body_button": "Update mijn email", + "email_invite_body_start": "{name}, {inviterName} heeft je uitgenodigd om lid te worden van een gedecentraliseerde sociale-mediaserver {serverName}.", + "email_invite_body_start_approved": "{name}, je verzoek om lid te worden van {serverName} is goedgekeurd door het serverpersoneel.", + "email_invite_body_end_html": "Klik op onderstaande knop om u aan te melden.", + "email_invite_body_end_plain": "Volg de onderstaande link om de registratie te starten.", + "email_invite_subject": "Uitnodiging om lid te worden van {serverName}", + "email_invite_subject_approved": "Uw verzoek om lid te worden van {serverName} is goedgekeurd", + "begin_registration": "Registratie starten", + "email_account_frozen_subject": "{domain}: account bevroren", + "email_account_frozen_body": "{name}, uw account op {serverName} is bevroren tot {date} wegens het overtreden van de serverregels. Herhaalde overtredingen kunnen leiden tot langere bevriezingen en een permanente schorsing.", + "email_account_suspended_subject": "{domain}: account opgeschort", + "email_account_suspended_body": "{name}, je account op {serverName} is opgeschort wegens het overtreden van de serverregels en zal worden verwijderd {deletionDate}. Je kunt nog steeds je gegevens exporteren, een omleiding instellen en je volgers naar een andere server verplaatsen.", + "email_confirmation_code": "{name}, gebruik deze code om {action} te bevestigen:", + "email_confirmation_code_info": "Deel deze code met niemand. Als u er niet om heeft gevraagd, wijzig dan onmiddellijk uw wachtwoord en beëindig eventuele verdachte sessies.", + "email_confirmation_code_subject": "{domain}: actiebevestiging", + "email_confirm_action_unfreeze": "het deblokkeren van uw account" +} \ No newline at end of file diff --git a/src/main/resources/langs/nl/feed.json b/src/main/resources/langs/nl/feed.json new file mode 100644 index 00000000..ff763d32 --- /dev/null +++ b/src/main/resources/langs/nl/feed.json @@ -0,0 +1,17 @@ +{ + "feed_retoot": "{author} heeft een bericht gedeeld:", + "feed_retoot_comment": "{author} deelde een reactie:", + "feed_added_friend": "{author} heeft {target} toegevoegd als vriend.", + "feed_added_friend_name": "{name}", + "feed_joined_group": "{author} sloot zich aan bij groep {target}.", + "feed_empty": "Updates van je vrienden verschijnen hier.", + "feed_tab_news": "Nieuws", + "feed_tab_comments": "Opmerkingen", + "summary_feed": "Laat het laatste nieuws zien", + "feed_joined_event": "{author} is aanwezig bij evenement {target}.", + "feed_created_group": "{author} heeft de groep {target} aangemaakt.", + "feed_created_event": "{author} heeft de gebeurtenis {target} gemaakt.", + "feed_added_multiple_friends": "{author} heeft {count, plural, one {# vriend} other {# vrienden}} toegevoegd:", + "feed_joined_multiple_groups": "{author} heeft zich {count, plural, one {# groep} other {# groepen}} aangesloten:", + "feed_joined_multiple_events": "{author} is aanwezig bij {count, plural, one {# evenement} other {# evenementen}}:" +} \ No newline at end of file diff --git a/src/main/resources/langs/nl/friends.json b/src/main/resources/langs/nl/friends.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/friends.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/global.json b/src/main/resources/langs/nl/global.json new file mode 100644 index 00000000..d7909456 --- /dev/null +++ b/src/main/resources/langs/nl/global.json @@ -0,0 +1,3 @@ +{ + "lang_name": "Nederlands" +} \ No newline at end of file diff --git a/src/main/resources/langs/nl/groups.json b/src/main/resources/langs/nl/groups.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/groups.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/mail.json b/src/main/resources/langs/nl/mail.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/mail.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/notifications.json b/src/main/resources/langs/nl/notifications.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/notifications.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/profile.json b/src/main/resources/langs/nl/profile.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/profile.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/reporting.json b/src/main/resources/langs/nl/reporting.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/reporting.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/settings.json b/src/main/resources/langs/nl/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/nl/wall.json b/src/main/resources/langs/nl/wall.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/main/resources/langs/nl/wall.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/langs/pl/admin.json b/src/main/resources/langs/pl/admin.json index b17b4d1c..3f096bfb 100644 --- a/src/main/resources/langs/pl/admin.json +++ b/src/main/resources/langs/pl/admin.json @@ -10,11 +10,6 @@ "invited_by": "Zaproszono przez", "signup_date": "Data rejestracji", "actions": "Działania", - "access_level": "Poziom dostępu", - "choose_access_level_for_X": "Wybierz poziom dostępu dla {name}", - "access_level_regular": "Zwykłe konto", - "access_level_moderator": "Moderator", - "access_level_admin": "Administrator", "admin_signup_mode": "Tryb rejestracji", "admin_signup_mode_open": "Otwarta", "admin_signup_mode_invite": "Tylko z zaproszeniem", @@ -34,16 +29,9 @@ "admin_server_short_description": "Krótki opis", "admin_server_policy": "Zasady", "admin_server_info_html_explain": "Możesz używać HTML-a w polach opisu i zasad.", - "admin_ban": "Zbanuj", - "admin_unban": "Odbanuj", - "admin_ban_X_confirm": "Czy na pewno chcesz zbanować konto {name}?", - "admin_unban_X_confirm": "Czy na pewno chcesz odbanować konto {name}?", - "ban_reason": "Powód zbanowania", "admin_activate_account": "Aktywuj", "signup_request_sent_at": "Wysłano", "menu_reports": "Zgłoszenia", "report_from": "Od", - "report_sent_at": "Wysłano", - "report_action_delete_post": "Usuń wpis", - "report_action_delete_comment": "Usuń komentarz" + "report_sent_at": "Wysłano" } \ No newline at end of file diff --git a/src/main/resources/langs/pl/global.json b/src/main/resources/langs/pl/global.json index 30c8b51b..df8e7aa7 100644 --- a/src/main/resources/langs/pl/global.json +++ b/src/main/resources/langs/pl/global.json @@ -70,7 +70,6 @@ "about_contact": "Kontakt", "about_admins": "Administratorzy", "about_on_this_server": "Na serwerze jest", - "your_account_is_banned": "Twoje konto zostało zbanowane przez administratorów tego serwera.", "show_previous_comments": "Pokaż poprzednie komentarze", "comments_show_X_replies": "Pokaż {count, plural, one {# odpowiedź} few {# odpowiedzi} other {# odpowiedzi}}", "search": "Szukaj", @@ -80,7 +79,6 @@ "search_groups": "Grupy", "search_external_objects": "Obiekty zewnętrzne", "nothing_found": "Nic nie znaleziono", - "max_file_size_exceeded": "Ten plik jest większy niż maksymalny rozmiar {size} MB.", "in_reply_to_X": "W odpowiedzi do {name}", "in_reply_to_name": "{name}", "X_days": "{count, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}", diff --git a/src/main/resources/langs/ru/admin.json b/src/main/resources/langs/ru/admin.json index e56bd02f..345c7b0f 100644 --- a/src/main/resources/langs/ru/admin.json +++ b/src/main/resources/langs/ru/admin.json @@ -10,11 +10,8 @@ "invited_by": "Кто пригласил", "signup_date": "Дата регистрации", "actions": "Действия", - "access_level": "Уровень доступа", - "choose_access_level_for_X": "Выберите уровень доступа для {name, inflect, genitive}", - "access_level_regular": "Обычный пользователь", - "access_level_moderator": "Модератор", - "access_level_admin": "Администратор", + "role": "Роль", + "choose_role_for_X": "Выберите роль для {name, inflect, genitive}", "admin_signup_mode": "Режим регистрации", "admin_signup_mode_open": "Открытый", "admin_signup_mode_invite": "По приглашениям", @@ -34,11 +31,6 @@ "admin_server_short_description": "Краткое описание", "admin_server_policy": "Правила", "admin_server_info_html_explain": "В полях описания и правил можно использовать HTML.", - "admin_ban": "Забанить", - "admin_unban": "Разбанить", - "admin_ban_X_confirm": "Вы действительно хотите заблокировать аккаунт {name, inflect, accusative}?", - "admin_unban_X_confirm": "Вы действительно хотите разблокировать аккаунт {name, inflect, accusative}?", - "ban_reason": "Причина блокировки", "sync_friends_and_groups": "Обновить друзей и группы", "sync_members": "Обновить список участников", "sync_content": "Обновить контент", @@ -65,13 +57,7 @@ "report_comment": "Дополнительная информация от пользователя", "private_post_warning_title": "У вас на самом деле нет доступа к этой записи", "private_group_post_warning": "Эта запись размещена в {groupType, select, private {частной} other {закрытой}} группе, в которой вы не состоите. Вы видите её только потому, что перешли к ней из жалобы.", - "mark_report_resolved": "Пометить решённой", - "report_action_delete_post": "Удалить запись", - "report_action_delete_post_locally": "Удалить запись локально", - "report_action_delete_comment": "Удалить комментарий", - "report_action_delete_comment_locally": "Удалить комментарий локально", "report_action_add_cw": "Добавить спойлер", - "report_action_suspend": "Заблокировать...", "admin_federation": "Федерация", "search_server_domain": "Домен сервера", "summary_X_servers": "Всего {count, plural, one {# сервер} few {# сервера} other {# серверов}}", @@ -111,5 +97,182 @@ "admin_captcha_signup_form": "В форме регистрации", "server_stats_activities_sent": "Отправленные активити", "server_stats_activities_received": "Полученные активити", - "server_stats_delivery_errors": "Ошибки отправки" + "server_stats_delivery_errors": "Ошибки отправки", + "menu_users": "Пользователи", + "menu_access": "Федерация и доступ", + "menu_stats": "Статистика", + "admin_invites": "Приглашения", + "role_none": "(без роли)", + "role_owner": "Владелец", + "role_admin": "Администратор", + "role_moderator": "Модератор", + "admin_server_settings": "Сервер", + "admin_roles": "Роли", + "admin_roles_summary": "{count, plural, one {# роль} few {# роли} other {# ролей}}", + "admin_create_role": "Создать новую", + "admin_permission_superuser": "Суперпользователь", + "admin_permission_server_settings": "Изменение настроек сервера", + "admin_permission_rules": "Управление правилами", + "admin_permission_roles": "Управление ролями пользователей", + "admin_permission_audit_log": "Просмотр журнала действий", + "admin_permission_manage_users": "Управление пользователями", + "admin_permission_user_access": "Управление доступом пользователей", + "admin_permission_reports": "Работа с жалобами", + "admin_permission_federation": "Управление федерацией", + "admin_permission_blocking_rules": "Управление блокировками", + "admin_permission_invites": "Управление приглашениями", + "admin_permission_announcements": "Управление объявлениями", + "admin_permission_delete_users": "Удаление страниц и данных пользователей", + "admin_permission_manage_groups": "Управление группами и встречами", + "admin_visible_in_staff": "Показывать в администрации", + "admin_permission_descr_superuser": "Имеет все права доступа и может вообще всё", + "admin_permission_descr_server_settings": "Позволяет изменять название сервера, его описание, режим регистрации и другие настройки", + "admin_permission_descr_rules": "Позволяет редактировать, добавлять и удалять правила сервера", + "admin_permission_descr_roles": "Позволяет редактировать, создавать и назначать пользовательские роли такого же или более низкого уровня", + "admin_permission_descr_audit_log": "Позволяет просматривать журнал всех административных действий на сервере", + "admin_permission_descr_manage_users": "Позволяет просматривать личные карточки пользователей и выполнять административные действия с их страницами", + "admin_permission_descr_user_access": "Позволяет сбрасывать пароли и менять почтовые адреса пользователей", + "admin_permission_descr_reports": "Позволяет просматривать жалобы и выполнять действия с ними", + "admin_permission_descr_federation": "Позволяет менять ограничения федерации на этом сервере", + "admin_permission_descr_blocking_rules": "Позволяет блокировать почтовые сервисы и IP-адреса", + "admin_permission_descr_invites": "Позволяет просматривать все активные пригласительные ссылки и деактивировать их", + "admin_permission_descr_announcements": "Позволяет создавать и редактировать объявления на сервере", + "admin_permission_descr_delete_users": "Позволяет удалять страницы пользователей мгновенно, без 30-дневного ожидания", + "admin_permission_descr_manage_groups": "Позволяет просматривать карточки групп и выполнять административные действия с ними", + "admin_visible_in_staff_descr": "Показывать пользователей с этой ролью в секции \"Администрация\" на странице \"Об этом сервере\"", + "admin_delete_role": "Удалить роль", + "admin_delete_role_confirm": "Вы действительно хотите убрать эту роль со всех пользователей, которым она назначена, и удалить её? Это действие невозможно отменить.", + "admin_role_name": "Название роли", + "admin_permissions": "Права доступа", + "admin_role_X_saved": "Роль \"{name}\" сохранена", + "admin_role_X_created": "Роль \"{name}\" создана", + "admin_no_permissions_selected": "Выберите хотя бы одно право доступа", + "admin_edit_role_title": "Редактирование роли", + "admin_create_role_title": "Создание роли", + "admin_audit_log": "Журнал действий", + "admin_audit_log_summary": "{count, plural, =0 {Нет записей} one {Всего # запись} few {Всего # записи} other {Всего # записей}} в журнале", + "admin_audit_log_empty": "Здесь появятся записи об административных действиях", + "admin_audit_log_created_role": "{name} {gender, select, female {создала} other {создал}} роль \"{roleName}\"", + "admin_audit_log_edited_role": "{name} {gender, select, female {отредактировала} other {отредактировал}} роль \"{roleName}\"", + "admin_audit_log_deleted_role": "{name} {gender, select, female {удалила} other {удалил}} роль \"{roleName}\"", + "admin_audit_log_assigned_role": "{name} {gender, select, female {назначила} other {назначил}} {targetName, inflect, dative} роль \"{roleName}\"", + "admin_audit_log_unassigned_role": "{name} {gender, select, female {разжаловала} other {разжаловал}} {targetName, inflect, accusative}", + "admin_user_location_any": "Любое размещение", + "admin_user_location_local": "Локальные", + "admin_user_location_remote": "С других серверов", + "summary_X_users": "Всего {count, plural, one {# пользователь} few {# пользователя} other {# пользователей}}", + "summary_X_users_found": "{count, plural, =0 {Не найдено ни одного пользователя} one {Найден # пользователь} few {Найдено # пользователя} other {Найдено # пользователей}}", + "admin_user_email_domain": "Домен почты", + "admin_user_ip_or_subnet": "IP-адрес или подсеть", + "any_role": "(любая)", + "search_users": "Поиск пользователей", + "no_users_found": "Не найдено ни одного пользователя, соответствующего этим критериям", + "admin_last_user_activity": "Активность", + "admin_manage_user": "Личная карточка", + "admin_user_staff_notes": "Служебные заметки", + "admin_account_id_tooltip": "ID локальной учётной записи", + "admin_ap_actor_id_tooltip": "ID актора ActivityPub", + "admin_account_activation_status": "Активация", + "admin_account_activated": "Почта подтверждена", + "admin_account_email_unconfirmed": "Почта не подтверждена", + "admin_account_email_change_pending": "Было запрошено изменение почты на {newEmail}, новый адрес не подтверждён", + "admin_others_with_this_domain": "Другие с этим доменом", + "admin_actor_last_updated": "Последнее обновление", + "admin_end_session": "Завершить", + "admin_audit_log_activated_account": "{name} {gender, select, female {активировала} other {активировал}} учётную запись {targetName, inflect, accusative}", + "admin_audit_log_changed_email": "{name} {gender, select, female {изменила} other {изменил}} email {targetName, inflect, dative}", + "admin_audit_log_reset_password": "{name} {gender, select, female {сбросила} other {сбросил}} пароль {targetName, inflect, dative}", + "admin_audit_log_ended_session": "{name} {gender, select, female {завершила} other {завершил}} один из сеансов {targetName, inflect, dative}", + "admin_session_terminated": "Сеанс завершён", + "admin_user_restrictions": "Ограничения", + "admin_user_change_restrictions": "Изменить ограничения...", + "admin_ban_user_title": "Ограничения для пользователя", + "admin_user_no_restrictions": "Нет ограничений", + "admin_user_freeze": "Заморозить", + "admin_user_freeze_explain": "Временная блокировка доступа пользователя к своей странице", + "admin_user_suspend": "Заблокировать", + "admin_user_suspend_explain": "Полная блокировка доступа пользователя к своей странице, скрытие страницы и автоматическое удаление через 30 дней", + "admin_user_foreign_suspend_explain": "Полная блокировка любых взаимодействий этого пользователя с любыми пользователями и контентом на вашем сервере", + "admin_user_hide": "Скрыть", + "admin_user_hide_explain": "Страница будет доступна для просмотра только авторизованным пользователям", + "admin_user_ban_message": "Сообщение", + "admin_user_ban_message_explain": "Будет показано заблокированному пользователю", + "admin_user_ban_duration": "Длительность", + "admin_user_ban_until_first_login": "До первого входа", + "admin_user_ban_force_password_change": "Принудительно сменить пароль", + "admin_user_state_no_restrictions": "Ограничений не наложено.", + "admin_user_state_frozen": "Страница заморожена до {expirationTime}.", + "admin_user_state_suspended": "Страница заблокирована и будет удалена {deletionTime}.", + "admin_user_state_suspended_foreign": "Этот пользователь с другого сервера заблокирован на вашем сервере.", + "admin_user_state_self_deactivated": "Страница деактивирована пользователем и будет удалена {deletionTime}.", + "admin_user_state_hidden": "Страница скрыта от неавторизованных пользователей.", + "admin_user_delete_account_now": "Удалить сейчас", + "admin_user_delete_account_confirmation": "Вы собираетесь немедленно удалить страницу {name, inflect, genitive}.\n\nЭто действие НЕОБРАТИМО, даже если у вас есть резервная копия базы данных и медиафайлов. Чтобы продолжить, введите ниже имя пользователя с доменом для удаляемой учётной записи.", + "admin_user_delete_account_title": "Удаление страницы", + "admin_audit_log_changed_user_restrictions": "{name} {gender, select, female {изменила} other {изменил}} ограничения для страницы {targetName, inflect, genitive}", + "admin_user_delete_wrong_username": "Неверное имя пользователя", + "admin_user_deleted_successfully": "Страница пользователя успешно удалена", + "admin_audit_log_deleted_user_account": "{name} {gender, select, female {удалила} other {удалил}} страницу {targetName}", + "admin_report_details": "Просмотр жалобы", + "admin_report_title_X": "Жалоба №{id}", + "admin_report_no_actions": "История действий с жалобой и комментарии к ней появятся здесь", + "admin_report_content_post": "Запись №{id}", + "admin_report_content_comment": "Комментарий №{id}", + "admin_report_content_message": "Сообщение №{id}", + "admin_report_content": "Контент", + "mark_report_unresolved": "Пометить нерешённой", + "report_action_delete_content": "Удалить контент...", + "report_action_delete_content_locally": "Удалить контент локально...", + "report_action_reject": "Отклонить", + "report_action_limit_user": "Ограничить пользователя...", + "report_action_delete_and_limit": "Удалить и ограничить...", + "report_state_open": "Открыта", + "report_state_resolved": "Решена", + "report_state_rejected": "Отклонена", + "report_log_rejected": "{name} {gender, select, female {отклонила} other {отклонил}} эту жалобу", + "report_log_reopened": "{name} {gender, select, female {пометила} other {пометил}} эту жалобу нерешённой", + "report_log_commented": "{name} {gender, select, female {оставила} other {оставил}} комментарий:", + "report_content_created_at": "Создано", + "report_confirm_delete_content": "Вы действительно хотите удалить контент, прикреплённый к этой жалобе? Это действие необратимо.", + "report_delete_content_title": "Удаление контента", + "report_delete_content_checkbox": "Также удалить контент, прикреплённый к этой жалобе", + "report_delete_content_checkbox_explanation": "Это действие необратимо", + "report_log_deleted_content": "{name} {gender, select, female {удалила} other {удалил}} контент, прикреплённый к жалобе", + "reports_of_user": "На {name, inflect, accusative}", + "reports_by_user": "Созданные {name, inflect, instrumental}", + "admin_user_staff_notes_empty": "Добавьте заметку об этом пользователе для других администраторов сервера или для себя в будущем. Заметки видны только тем, у кого есть доступ к личным карточкам.", + "admin_user_X_staff_notes_summary": "{count, plural, =0 {Нет служебных заметок} one {# служебная заметка} few {# служебных заметки} other {# служебных заметок}} об этом пользователе", + "admin_user_staff_note_confirm_delete": "Вы действительно хотите удалить эту заметку?", + "admin_email_domain_rules": "Домены e-mail", + "admin_email_X_domain_rules_summary": "{count, plural, =0 {Нет правил} one {# правило} few {# правила} other {# правил}} для доменов e-mail", + "admin_create_rule": "Создать правило", + "admin_email_rule_reject": "Запретить регистрацию", + "admin_email_rule_review": "Отправлять регистрации на ручное одобрение", + "admin_email_domain_rules_empty": "Нет ни одного правила для доменов e-mail", + "admin_rule_note": "Заметка", + "admin_rule_note_explanation": "Вы можете добавить к этому правилу заметку для других администраторов или для себя в будущем", + "admin_rule_action": "Действие", + "admin_audit_log_created_email_rule": "{name} {gender, select, female {создала} other {создал}} правило для домена e-mail {domain}:", + "admin_audit_log_updated_email_rule": "{name} {gender, select, female {изменила} other {изменил}} правило для домена e-mail {domain}:", + "admin_audit_log_deleted_email_rule": "{name} {gender, select, female {удалила} other {удалил}} правило для домена e-mail {domain}:", + "err_admin_email_rule_already_exists": "Для этого домена уже существует правило", + "admin_email_rule_created": "Правило создано", + "admin_email_rule_title": "Правило для домена e-mail", + "admin_confirm_delete_rule": "Вы действительно хотите удалить это правило?", + "admin_ip_rules": "IP-адреса", + "admin_ip_rule_address": "IP или подсеть", + "admin_ip_rule_expiry": "Срок действия", + "admin_ip_rule_expiry_explanation": "IP-адреса — это ограниченный ресурс. Они достаточно часто меняют владельцев, так что блокировки по IP на неограниченный срок не рекомендуются", + "admin_audit_log_created_ip_rule": "{name} {gender, select, female {создала} other {создал}} правило для IP {ipOrSubnet}:", + "admin_audit_log_updated_ip_rule": "{name} {gender, select, female {изменила} other {изменил}} правило для IP {ipOrSubnet}:", + "admin_audit_log_deleted_ip_rule": "{name} {gender, select, female {удалила} other {удалил}} правило для IP {ipOrSubnet}:", + "admin_X_ip_rules_summary": "{count, plural, =0 {Нет правил} one {# правило} few {# правила} other {# правил}} для IP-адресов", + "admin_ip_rule_until_time": "{action} до {time}", + "admin_ip_rules_empty": "Нет ни одного правила для IP-адресов", + "admin_ip_rule_title": "Правило для IP или подсети", + "err_admin_ip_format_invalid": "Некорректный IP-адрес. Введите IPv4- или IPv6-адрес или диапазон адресов в формате CIDR.", + "admin_ip_rule_created": "Правило для IP создано", + "summary_admin_X_signup_invites": "Всего на сервере {count, plural, =0 {0 приглашений} one {# приглашение} few {# приглашения} other {# приглашений}}", + "admin_audit_log_deleted_invite": "{name} {gender, select, female {удалила} other {удалил}} приглашение {targetName, inflect, dative}", + "signup_invite_deleted": "Приглашение удалено" } \ No newline at end of file diff --git a/src/main/resources/langs/ru/email.json b/src/main/resources/langs/ru/email.json index 92dd87ec..1117ac31 100644 --- a/src/main/resources/langs/ru/email.json +++ b/src/main/resources/langs/ru/email.json @@ -21,5 +21,13 @@ "email_invite_body_end_plain": "Перейдите по ссылке ниже, чтобы начать регистрацию.", "email_invite_subject": "Приглашение зарегистрироваться на {serverName}", "email_invite_subject_approved": "Ваш запрос на регистрацию на {serverName} одобрен", - "begin_registration": "Начать регистрацию" + "begin_registration": "Начать регистрацию", + "email_account_frozen_subject": "{domain}: страница заморожена", + "email_account_frozen_body": "{name}, ваша страница на {serverName} была заморожена до {date} за нарушение правил сервера. Повторные нарушения могут привести к более длительным заморозкам и перманентной блокировке.", + "email_account_suspended_subject": "{domain}: страница заблокирована", + "email_account_suspended_body": "{name}, ваша страница на {serverName} была заблокирована администрацией за нарушение правил сервера и будет удалена {deletionDate}. Вы всё ещё можете выгрузить свои данные, настроить перенаправление и перенести подписчиков на другой сервер.", + "email_confirmation_code": "{name}, используйте этот код для подтверждения {action}:", + "email_confirmation_code_info": "Никому не сообщайте этот код. Если вы его не запрашивали, срочно смените пароль и завершите любые подозрительные сеансы.", + "email_confirmation_code_subject": "{domain}: подтверждение действия", + "email_confirm_action_unfreeze": "разморозки вашей страницы" } \ No newline at end of file diff --git a/src/main/resources/langs/ru/friends.json b/src/main/resources/langs/ru/friends.json index 363557e1..c70f8c6e 100644 --- a/src/main/resources/langs/ru/friends.json +++ b/src/main/resources/langs/ru/friends.json @@ -54,5 +54,7 @@ "friend_list_your_friends": "Ваши друзья", "friends_in_list": "Друзья в списке", "select_friends_empty_selection": "Вы можете выбрать друзей в списке слева.", - "select_friends_button": "Выбрать друзей" + "select_friends_button": "Выбрать друзей", + "X_followers": "{count, plural, =0 {Нет подписчиков} one {# подписчик} few {# подписчика} other {# подписчиков}}", + "X_following": "{count, plural, =0 {Нет подписок} one {# подписка} few {# подписки} other {# подписок}}" } \ No newline at end of file diff --git a/src/main/resources/langs/ru/global.json b/src/main/resources/langs/ru/global.json index b27fc773..b9933758 100644 --- a/src/main/resources/langs/ru/global.json +++ b/src/main/resources/langs/ru/global.json @@ -71,7 +71,6 @@ "about_admins": "Администраторы", "about_software": "Этот сервер работает под управлением {sw} версии {version}.", "about_on_this_server": "На этом сервере", - "your_account_is_banned": "Ваша учётная запись заблокирована администрацией сервера.", "show_previous_comments": "Показать предыдущие комментарии", "comments_show_X_replies": "Показать {count, plural, one {# ответ} few {# ответа} other {# ответов}}", "search": "Поиск", @@ -83,7 +82,6 @@ "search_groups": "Группы", "search_external_objects": "Внешние объекты", "nothing_found": "Ничего не найдено", - "max_file_size_exceeded": "Этот файл превышает максимальный размер в {size} Мб.", "in_reply_to_X": "В ответ {name}", "in_reply_to_name": "{name, inflect, dative}", "X_days": "{count, plural, one {# день} few {# дня} other {# дней}}", @@ -128,5 +126,36 @@ "remote_object_loading_error": "При получении объекта с другого сервера произошла ошибка.", "err_access_user_content": "Пользователь предпочёл скрыть эту страницу.", "menu_mail": "Мои Сообщения", - "content_type_message": "Сообщение" + "content_type_message": "Сообщение", + "X_users": "{count, plural, =0 {Нет пользователей} one {# пользователь} few {# пользователя} other {# пользователей}}", + "change": "Изменить", + "ip_address": "IP-адрес", + "message_from_staff": "Сообщение от администрации", + "account_frozen": "Страница временно заблокирована", + "account_suspended": "Страница заблокирована", + "account_deactivated": "Страница деактивирована", + "account_frozen_info": "{name}, мы временно заморозили вашу страницу за нарушение правил сервера.", + "account_frozen_info_time": "Вы сможете разморозить свою страницу {time}.", + "account_ban_message": "Эта страница была заблокирована модератором с таким комментарием:", + "account_frozen_unfreeze": "Чтобы разморозить страницу, мы просим вас подтвердить, что вы её владелец.", + "account_frozen_unfreeze_password": "Чтобы разморозить страницу, мы просим вас подтвердить, что вы её владелец, и сменить пароль.", + "unfreeze_my_account": "Разморозить страницу", + "account_suspended_info": "{name}, мы заблокировали вашу страницу навсегда за неоднократные нарушения правил сервера. Ваша страница будет удалена {deletionTime}. До этого времени вы можете выгрузить свои данные или настроить перенаправление на другой сервер и перенести подписчиков.", + "account_ban_contact": "Если у вас есть вопросы, свяжитесь с нами.", + "account_deactivated_info": "Вы деактивировали свою страницу. Она будет необратимо удалена {deletionTime}.", + "account_reactivate": "Восстановить страницу", + "action_confirmation": "Подтверждение действия", + "action_confirmation_text": "Мы отправили вам код на {maskedEmail}. Пожалуйста, введите его для подтверждения {action}:", + "next": "Далее", + "action_confirmation_incorrect_code": "Неверный код", + "restore": "Восстановить", + "cw_default": "Содержимое деликатного характера", + "err_file_upload": "Сервер не смог сохранить ваш файл из-за внутренней ошибки. Повторите попытку позже.", + "err_file_upload_image_format": "Загруженное изображение повреждено или его формат не поддерживается. Попробуйте преобразовать его в JPEG или PNG.", + "err_file_upload_too_large": "Ваш файл превышает максимальный размер в {maxSize}.", + "file_size_bytes": "{amount} байт", + "file_size_kilobytes": "{amount} Кб", + "file_size_megabytes": "{amount} Мб", + "file_size_gigabytes": "{amount} Гб", + "file_size_terabytes": "{amount} Тб" } \ No newline at end of file diff --git a/src/main/resources/langs/ru/profile.json b/src/main/resources/langs/ru/profile.json index bcea258c..6979cd14 100644 --- a/src/main/resources/langs/ru/profile.json +++ b/src/main/resources/langs/ru/profile.json @@ -12,5 +12,9 @@ "incomplete_profile": "Информация на этой странице может быть неполной.", "this_is_you": "(это вы)", "profile_moved": "{name} теперь пользуется другой страницей:", - "profile_moved_link": "{name} на {domain}" + "profile_moved_link": "{name} на {domain}", + "profile_deactivated": "Страница удалена её владельцем.", + "profile_hidden": "Скрытая страница", + "profile_hidden_info": "Для просмотра этой страницы нужно войти.", + "profile_banned": "Страница заблокирована администрацией сервера." } \ No newline at end of file diff --git a/src/main/resources/langs/ru/settings.json b/src/main/resources/langs/ru/settings.json index ae48f613..d59c0db3 100644 --- a/src/main/resources/langs/ru/settings.json +++ b/src/main/resources/langs/ru/settings.json @@ -147,5 +147,17 @@ "privacy_enter_friend_name": "Введите имя друга", "privacy_settings_saved": "Настройки приватности сохранены", "privacy_settings_value_except": ", кроме ", - "privacy_settings_value_except_name": "{name, inflect, genitive}" + "privacy_settings_value_certain_friends_before": ": ", + "privacy_settings_value_except_name": "{name, inflect, genitive}", + "privacy_settings_value_name_separator": ", ", + "settings_sessions": "История активности", + "settings_activity_access_type": "Тип доступа", + "settings_activity_access_time": "Время", + "settings_activity_web": "{browserName} {browserVersion} на {osName}", + "unknown_browser": "Неизвестный браузер", + "settings_deactivate_confirm": "Вы действительно хотите удалить свою страницу?\n\nВаша страница будет деактивирована, а через {timeInterval} — необратимо удалена. Если вы передумаете, вы сможете восстановить свою страницу без потери данных в течение периода деактивации.", + "settings_deactivate_account": "Вы можете удалить свою страницу.", + "settings_reactivate_confirm": "Введите ваш пароль для восстановления своей страницы и отмены её удаления.", + "settings_reactivate_title": "Восстановление страницы", + "err_reg_email_domain_not_allowed": "Регистрации с e-mail этого почтового сервиса запрещены на этом сервере" } \ No newline at end of file diff --git a/src/main/resources/langs/ru/wall.json b/src/main/resources/langs/ru/wall.json index 5fd1f639..2914c4d6 100644 --- a/src/main/resources/langs/ru/wall.json +++ b/src/main/resources/langs/ru/wall.json @@ -62,5 +62,6 @@ "graffiti_on_group_X_wall": "Граффити на стене \"{name}\"", "post_visible_to_followers": "Эта запись видна только подписчикам {name, inflect, genitive}", "post_visible_to_followers_mentioned": "Эта запись видна только подписчикам {name, inflect, genitive} и упомянутым", - "post_visible_to_friends": "Эта запись видна только друзьям {name, inflect, genitive}" + "post_visible_to_friends": "Эта запись видна только друзьям {name, inflect, genitive}", + "expand_all_cws": "Раскрыть все спойлеры" } \ No newline at end of file diff --git a/src/main/resources/langs/uk/global.json b/src/main/resources/langs/uk/global.json index 9e26dfee..23b357b6 100644 --- a/src/main/resources/langs/uk/global.json +++ b/src/main/resources/langs/uk/global.json @@ -1 +1,3 @@ -{} \ No newline at end of file +{ + "lang_name": "Українська" +} \ No newline at end of file diff --git a/src/main/resources/public/res/spacer.gif b/src/main/resources/public/res/spacer.gif new file mode 100644 index 00000000..2fac20f1 Binary files /dev/null and b/src/main/resources/public/res/spacer.gif differ diff --git a/src/main/resources/templates/common/account_banned.twig b/src/main/resources/templates/common/account_banned.twig new file mode 100644 index 00000000..86e72fb4 --- /dev/null +++ b/src/main/resources/templates/common/account_banned.twig @@ -0,0 +1,46 @@ +{# @pebvariable name="status" type="smithereen.model.UserBanStatus" #} +{# @pebvariable name="banInfo" type="smithereen.model.UserBanInfo" #} +{% extends "page" %} +{% block leftMenu %} +{% if isMobile %} + +{% endif %} +{% endblock %} +{% block content %} +
+ {% if status=='FROZEN' %} +

{{ L('account_frozen') }}

+

{{ L('account_frozen_info', {'name': currentUser | name('first')}) }}

+ {% if unfreezeTime is not null %} +

{{ L('account_frozen_info_time', {'time': LD(unfreezeTime)}) }} + {% endif %} + {% elseif status=='SUSPENDED' %} +

{{ L('account_suspended') }}

+

{{ L('account_suspended_info', {'deletionTime': LD(deletionTime), 'name': currentUser | name('first')}) }}

+ {% elseif status=='SELF_DEACTIVATED' %} +

{{ L('account_deactivated') }}

+

{{ L('account_deactivated_info', {'deletionTime': LD(deletionTime)}) }}

+ {% endif %} + {% if banInfo.message is not empty %} +

{{ L('account_ban_message') }}

+

{{ banInfo.message }}

+ {% endif %} + {% if status!='SELF_DEACTIVATED' and contactEmail is not empty %} +

{{ L('account_ban_contact', {}, {'email': {'href': "mailto:#{contactEmail}"} }) }}

+ {% endif %} + {% if status=='FROZEN' and unfreezeTime is null %} +

{{ L(banInfo.requirePasswordChange ? 'account_frozen_unfreeze_password' : 'account_frozen_unfreeze') }}

+ + {% endif %} + {% if status=='SELF_DEACTIVATED' %} + + {% endif %} +
+{% endblock %} + diff --git a/src/main/resources/templates/common/account_unfreeze_change_password_form.twig b/src/main/resources/templates/common/account_unfreeze_change_password_form.twig new file mode 100644 index 00000000..2575cca0 --- /dev/null +++ b/src/main/resources/templates/common/account_unfreeze_change_password_form.twig @@ -0,0 +1,6 @@ +{% import "forms" as form %} +{{ form.start("changePassword", passwordMessage) }} + {{ form.textInput("current", L('current_password'), "", {'type' : 'password'}) }} + {{ form.textInput("new", L('new_password'), "", {'type' : 'password'}) }} + {{ form.textInput("new2", L('new_password_confirm'), "", {'type' : 'password'}) }} +{{ form.end() }} diff --git a/src/main/resources/templates/common/actor_list.twig b/src/main/resources/templates/common/actor_list.twig index 602886bb..685d65e2 100644 --- a/src/main/resources/templates/common/actor_list.twig +++ b/src/main/resources/templates/common/actor_list.twig @@ -4,7 +4,7 @@ {% for actor in actors %} - {{ actor.actor | pictureForAvatar('s') }} + {{ actor.actor | pictureForAvatar('s') }} {{ actor.actor.type=="Person" ? actor.actor | name('complete') : actor.actor.name }} diff --git a/src/main/resources/templates/common/admin_access_tabbar.twig b/src/main/resources/templates/common/admin_access_tabbar.twig new file mode 100644 index 00000000..e7a854a0 --- /dev/null +++ b/src/main/resources/templates/common/admin_access_tabbar.twig @@ -0,0 +1,10 @@ +{# @pebvariable name="userPermissions" type="smithereen.model.UserPermissions" #} +
+ {% if userPermissions.hasPermission('MANAGE_FEDERATION') %} + {{ L('admin_federation') }} + {% endif %} + {% if userPermissions.hasPermission('MANAGE_BLOCKING_RULES') %} + {{ L('admin_email_domain_rules') }} + {{ L('admin_ip_rules') }} + {% endif %} +
\ No newline at end of file diff --git a/src/main/resources/templates/common/admin_audit_log.twig b/src/main/resources/templates/common/admin_audit_log.twig new file mode 100644 index 00000000..65c1e723 --- /dev/null +++ b/src/main/resources/templates/common/admin_audit_log.twig @@ -0,0 +1,31 @@ +{# @pebvariable name="entry" type="smithereen.model.viewmodel.AuditLogEntryViewModel" #} +{% extends "page" %} +{% block content %} +{% if user is null %} +{% include "admin_tabbar" with {'tab': 'auditLog'} %} +{% else %} +{% include "admin_users_info_tabbar" with {'tab': 'auditLog'} %} +{% endif %} +{% if not isMobile %} +
+
{{ L('admin_audit_log_summary', {'count': totalItems}) }}
+ {% include "pagination" %} +
+{% endif %} +
+{% for entry in items %} +
+ +
+
{{ entry.mainTextHtml | raw }}
+ {% if entry.extraTextHtml is not empty %}
{{ entry.extraTextHtml | raw }}
{% endif %} +
{{ LD(entry.entry.time) }}
+
+
+{% else %} +
{{ L('admin_audit_log_empty') }}
+{% endfor %} +
+
{% include "pagination" %}
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_ban_user.twig b/src/main/resources/templates/common/admin_ban_user.twig deleted file mode 100644 index c7b4b4af..00000000 --- a/src/main/resources/templates/common/admin_ban_user.twig +++ /dev/null @@ -1,4 +0,0 @@ -

- {{ L("admin_ban_X_confirm", {'name': targetAccount.user.firstLastAndGender}) }} -

- diff --git a/src/main/resources/templates/common/admin_delete_user_form.twig b/src/main/resources/templates/common/admin_delete_user_form.twig new file mode 100644 index 00000000..d876fcd6 --- /dev/null +++ b/src/main/resources/templates/common/admin_delete_user_form.twig @@ -0,0 +1,3 @@ +
{{ message }}
+

{{ L('admin_user_delete_account_confirmation', {'name': user.firstLastAndGender}) }}

+

\ No newline at end of file diff --git a/src/main/resources/templates/common/admin_edit_role.twig b/src/main/resources/templates/common/admin_edit_role.twig new file mode 100644 index 00000000..2f8f9e22 --- /dev/null +++ b/src/main/resources/templates/common/admin_edit_role.twig @@ -0,0 +1,19 @@ +{% extends "page" %} +{% import "forms" as form %} +{% block content %} +
+
+ {{ form.start("editRole", editRoleMessage) }} + {{ form.textInput("name", L('admin_role_name'), role.name, {'maxlength': 255, 'required': true}) }} + {% for permission in permissions %} + {{ form.checkBox(permission.toString(), loop.first ? L('admin_permissions') : '', L(permission.langKey), role.permissions contains permission, {'explanation': L(permission.descriptionLangKey), 'disabled': disabledPermissions contains permission}) }} + {% endfor %} + {% for permission in settings %} + {{ form.checkBox(permission.toString(), loop.first ? L('settings') : '', L(permission.langKey), role.permissions contains permission, {'explanation': L(permission.descriptionLangKey)}) }} + {% endfor %} + {{ form.footer(L(role is null ? 'create' : 'save')) }} + {{ form.end() }} +
+
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_email_rule_form.twig b/src/main/resources/templates/common/admin_email_rule_form.twig new file mode 100644 index 00000000..bae36d93 --- /dev/null +++ b/src/main/resources/templates/common/admin_email_rule_form.twig @@ -0,0 +1,13 @@ +{% import "forms" as form %} +{{ form.start("adminCreateEmailRule") }} + {% if editing %} + {{ form.labeledText(L('domain'), domain) }} + {% else %} + {{ form.textInput('domain', L('domain'), domain, {'required': true}) }} + {% endif %} + {{ form.radioGroup('ruleAction', L('admin_rule_action'), [ + {'value': 'MANUAL_REVIEW', 'label': L('admin_email_rule_review'), 'selected': ruleAction=='MANUAL_REVIEW'}, + {'value': 'BLOCK', 'label': L('admin_email_rule_reject'), 'selected': ruleAction=='BLOCK'} + ], {'required': true}) }} + {{ form.textInput('note', L('admin_rule_note'), note, {'explanation': L('admin_rule_note_explanation')}) }} +{{ form.end() }} diff --git a/src/main/resources/templates/common/admin_email_rules.twig b/src/main/resources/templates/common/admin_email_rules.twig new file mode 100644 index 00000000..b5c3b5d6 --- /dev/null +++ b/src/main/resources/templates/common/admin_email_rules.twig @@ -0,0 +1,36 @@ +{# @pebvariable name="rules" type="java.util.List" #} +{% extends "page" %} +{% block content %} +{% include "admin_access_tabbar" with {'tab': 'emailRules'} %} +{% if not isMobile %} +
+
+ {{ L('admin_email_X_domain_rules_summary', {'count': rules | length}) }} + | {{ L('admin_create_rule') }} +
+
+{% else %} + +{% endif %} +
+ {% if message is not empty %}
{{ message }}
{% endif %} + {% for rule in rules %} +
+
+ {{ rule.rule.domain }} — {{ L(rule.rule.action.langKey) }} +
+ {% if rule.note is not empty %}
{{ rule.note }}
{% endif %} +
+ {{ users[rule.creatorID] | name }}, {{ LD(rule.createdAt) }} +
+ +
+ {% else %} +
{{ L('admin_email_domain_rules_empty') }}
+ {% endfor %} +
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_invites.twig b/src/main/resources/templates/common/admin_invites.twig new file mode 100644 index 00000000..5eba25fd --- /dev/null +++ b/src/main/resources/templates/common/admin_invites.twig @@ -0,0 +1,34 @@ +{% extends "page" %} +{% block content %} +{% include "admin_users_tabbar" with {'tab': 'invites'} %} +{% if not isMobile %} +
+
{{ L('summary_admin_X_signup_invites', {'count': totalItems}) }}
+ {% include "pagination" %} +
+{% endif %} +
+
{{ message | raw }}
+{% for invite in items %} +
+ + {% if invite.firstName is not empty %} + {{ invite.firstName }}{% if invite.lastName is not empty %} {{ invite.lastName }}{% endif %}
+ {% endif %} + {{ L('invite_created_at', {'date': LD(invite.createdAt)}) }}
+ {{ L('X_signups_remaining', {'count': invite.signupsRemaining}) }}
+ {{ L('invitation_code') }}: {{ invite.code }}
+ {% if invite.email is not empty %} + {{ L('email') }}: {{ invite.email }}
+ {% endif %} + {{ L('delete') }} +
+{% else %} +
{{ L('no_invites') }}
+{% endfor %} +
+
{% include "pagination" %}
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_ip_rule_form.twig b/src/main/resources/templates/common/admin_ip_rule_form.twig new file mode 100644 index 00000000..aabbec6e --- /dev/null +++ b/src/main/resources/templates/common/admin_ip_rule_form.twig @@ -0,0 +1,26 @@ +{% import "forms" as form %} +{{ form.start("adminCreateIPRule") }} + {% if editing %} + {{ form.labeledText(L('admin_ip_rule_address'), ipAddress) }} + {% else %} + {{ form.textInput('ipAddress', L('admin_ip_rule_address'), ipAddress, {'required': true, 'placeholder': '123.0.0.0/8'}) }} + {% endif %} + {% set options=[ + {'value': 60, 'label': L('X_hours', {'count': 1})}, + {'value': 720, 'label': L('X_hours', {'count': 12})}, + {'value': 1440, 'label': L('X_days', {'count': 1}), 'selected': expiry is null}, + {'value': 2880, 'label': L('X_days', {'count': 2})}, + {'value': 10080, 'label': L('X_days', {'count': 7})}, + {'value': 20160, 'label': L('X_days', {'count': 14})}, + {'value': 43200, 'label': L('X_days', {'count': 30})}, + {'value': 86400, 'label': L('X_days', {'count': 60})}, + {'value': 129600, 'label': L('X_days', {'count': 90})} + ] %} + {% if expiry is not null %}{% set _=options.add({'value': 0, 'label': LD(expiry, true), 'selected': true}) %}{% endif %} + {{ form.select('expiry', L('admin_ip_rule_expiry'), options, {'required': true, 'explanation': L('admin_ip_rule_expiry_explanation')}) }} + {{ form.radioGroup('ruleAction', L('admin_rule_action'), [ + {'value': 'MANUAL_REVIEW_SIGNUPS', 'label': L('admin_email_rule_review'), 'selected': ruleAction=='MANUAL_REVIEW_SIGNUPS'}, + {'value': 'BLOCK_SIGNUPS', 'label': L('admin_email_rule_reject'), 'selected': ruleAction=='BLOCK_SIGNUPS'} + ], {'required': true}) }} + {{ form.textInput('note', L('admin_rule_note'), note, {'explanation': L('admin_rule_note_explanation')}) }} +{{ form.end() }} diff --git a/src/main/resources/templates/common/admin_ip_rules.twig b/src/main/resources/templates/common/admin_ip_rules.twig new file mode 100644 index 00000000..e1474433 --- /dev/null +++ b/src/main/resources/templates/common/admin_ip_rules.twig @@ -0,0 +1,36 @@ +{# @pebvariable name="rules" type="java.util.List" #} +{% extends "page" %} +{% block content %} +{% include "admin_access_tabbar" with {'tab': 'ipRules'} %} +{% if not isMobile %} +
+
+ {{ L('admin_X_ip_rules_summary', {'count': rules | length}) }} + | {{ L('admin_create_rule') }} +
+
+{% else %} + +{% endif %} +
+ {% if message is not empty %}
{{ message }}
{% endif %} + {% for rule in rules %} +
+
+ {{ rule.rule.ipRange }} — {{ L('admin_ip_rule_until_time', {'action': L(rule.rule.action.langKey), 'time': LD(rule.rule.expiresAt, true)}) }} +
+ {% if rule.note is not empty %}
{{ rule.note }}
{% endif %} +
+ {{ users[rule.creatorID] | name }}, {{ LD(rule.createdAt) }} +
+ +
+ {% else %} +
{{ L('admin_ip_rules_empty') }}
+ {% endfor %} +
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_roles.twig b/src/main/resources/templates/common/admin_roles.twig new file mode 100644 index 00000000..18613097 --- /dev/null +++ b/src/main/resources/templates/common/admin_roles.twig @@ -0,0 +1,30 @@ +{% extends "page" %} +{% block content %} +{% include "admin_tabbar" with {'tab': 'roles'} %} +{% if message is not empty %} +
{{ message }}
+{% endif %} +
+
+ {{- L('admin_roles_summary', {'count': roles | length}) -}} + | {{ L('admin_create_role') }} +
+
+
+ {% for role in roles %} +
+
{{ role.role.langKey is empty ? role.role.name : L(role.role.langKey) }}
+
+ {% for permission in role.role.permissions %}{{ L(permission.langKey) }}{% if not loop.last %}, {% endif %}{% endfor %} +
+ {{ L('X_users', {'count': role.numUsers}) }} + {%- if role.canEdit %} + | {{ L('edit') }} + {%- if role.role.id!=1 and role.role.id!=userPermissions.role.id %} + | {{ L('delete') }} + {% endif %} + {% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/src/main/resources/templates/common/admin_tabbar.twig b/src/main/resources/templates/common/admin_tabbar.twig index 0756f083..56c9909b 100644 --- a/src/main/resources/templates/common/admin_tabbar.twig +++ b/src/main/resources/templates/common/admin_tabbar.twig @@ -1,10 +1,15 @@ +{# @pebvariable name="userPermissions" type="smithereen.model.UserPermissions" #}
- {% if userPermissions.serverAccessLevel.ordinal>=3 %} - {{L('profile_edit_basic')}} + {% if userPermissions.hasPermission('MANAGE_SERVER_SETTINGS') %} + {{L('admin_server_settings')}} {% endif %} - {{L('admin_users')}} - {{ L('admin_federation') }} - {% if userPermissions.serverAccessLevel.ordinal>=3 %} + {% if userPermissions.hasPermission('MANAGE_ROLES') %} + {{L('admin_roles')}} + {% endif %} + {% if userPermissions.hasPermission('VIEW_SERVER_AUDIT_LOG') %} + {{ L('admin_audit_log') }} + {% endif %} + {% if userPermissions.hasPermission('MANAGE_SERVER_SETTINGS') %} {{L('admin_other')}} {% endif %}
\ No newline at end of file diff --git a/src/main/resources/templates/common/admin_users.twig b/src/main/resources/templates/common/admin_users.twig index a43fe62f..99f30131 100644 --- a/src/main/resources/templates/common/admin_users.twig +++ b/src/main/resources/templates/common/admin_users.twig @@ -1,41 +1,83 @@ -{%extends "page"%} -{%block content%} -{%include "admin_tabbar" with {'tab': 'users'}%} -
- - - - - - - - - {% for acc in items %} - - - - - - - + + {% else %} +
{{ L('no_users_found') }}
{% endfor %} -
ID{{L('name')}}{{L('invited_by')}}{{L('signup_date')}}{{L('actions')}}
{{acc.id}} ({{acc.user.id}}){{acc.user | pictureForAvatar('s', 32)}}{{ acc.user | name('full') }}{%if acc.invitedBy is not null%}{{acc.invitedBy | pictureForAvatar('s', 32)}}{{ acc.invitedBy | name('full') }}{%else%}—{%endif%}{{LD(acc.createdAt)}} - {% if acc.user.id!=currentUser.id and userPermissions.serverAccessLevel.ordinal>=3 %} - {{ L('access_level') }} +{% extends "page" %} +{% block content %} +{% if message is not empty %}
{{ message }}
{% endif %} +{% include "admin_users_tabbar" with {'tab': 'users'} %} +
+
+ + {% script %} + initAjaxSearch("userSearch"); + {% endscript %} +
+
+
+{% block ajaxPartialUpdate %} + {% if not isMobile %} +
+
{{ L(hasFilters ? 'summary_X_users_found' : 'summary_X_users', {'count': totalItems}) }}
+ {% include "pagination" %} +
+ {% endif %} +
+
+ {% for user in items %} +
+
+
{{ users[user.userID] | pictureForAvatar('s', 30) }}
+ +
@{{ users[user.userID].fullUsername }}, ID {{ user.userID }}
+
+ {% if user.lastActive is not null %} +
+ {{ L('admin_last_user_activity') }}: + {{ LD(user.lastActive) }} + {%- if user.lastIP is not null %}, {{ user.lastIP.hostAddress }}{% endif %} +
{% endif %} - {% if acc.accessLevel!='ADMIN' and acc.accessLevel!='MODERATOR' %} - {% if acc.banInfo is null %} - | {{ L('admin_ban') }} + {% if user.accountID!=0 %} +
+ {{ L('email') }}: + {{ user.emailDomain }} +
+
+ {{ L('role') }}: + {% if user.role!=0 %} + {{ rolesMap[user.role].langKey is empty ? rolesMap[user.role].name : L(rolesMap[user.role].langKey) }} {% else %} - | {{ L('admin_unban') }} + {{ L('role_none') }} {% endif %} +
{% endif %} - {% if acc.activationInfo is not null %} - | {{ L('admin_activate_account') }} - {% endif %} -
-
-
- {% include "pagination" %} +
+
+ {{ L('admin_user_location_any') }} + {{ L('admin_user_location_local') }} + {{ L('admin_user_location_remote') }} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
{% include "pagination" %}
+{% endblock %} -{%endblock%} \ No newline at end of file +{% endblock %} diff --git a/src/main/resources/templates/common/admin_users_access_level.twig b/src/main/resources/templates/common/admin_users_access_level.twig deleted file mode 100644 index dcfe70c6..00000000 --- a/src/main/resources/templates/common/admin_users_access_level.twig +++ /dev/null @@ -1,14 +0,0 @@ -

{{L('choose_access_level_for_X', {'name': targetAccount.user.firstLastAndGender})}}

- -
- - -
-
- - -
-
- - -
\ No newline at end of file diff --git a/src/main/resources/templates/common/admin_users_ban_form.twig b/src/main/resources/templates/common/admin_users_ban_form.twig new file mode 100644 index 00000000..e4a88c7d --- /dev/null +++ b/src/main/resources/templates/common/admin_users_ban_form.twig @@ -0,0 +1,32 @@ +{% import "forms" as form %} +{{ form.start("userBan") }} + {% if user.domain is empty %} + {{ form.radioGroup('status', '', [ + {'value': 'NONE', 'label': L('admin_user_no_restrictions'), 'selected': status=='NONE', 'skip': hideNone}, + {'value': 'FROZEN', 'label': L('admin_user_freeze'), 'explanation': L('admin_user_freeze_explain'), 'selected': status=='FROZEN'}, + {'value': 'SUSPENDED', 'label': L('admin_user_suspend'), 'explanation': L('admin_user_suspend_explain'), 'selected': status=='SUSPENDED'}, + {'value': 'HIDDEN', 'label': L('admin_user_hide'), 'explanation': L('admin_user_hide_explain'), 'selected': status=='HIDDEN'} + ], {'required': true}) }} + {{ form.textInput('message', L('admin_user_ban_message'), message, {'explanation': L('admin_user_ban_message_explain')}) }} + {{ form.select('duration', L('admin_user_ban_duration'), [ + {'value': '0', 'label': L('admin_user_ban_until_first_login')}, + {'value': '12', 'label': L('X_hours', {'count': 12})}, + {'value': '24', 'label': L('X_days', {'count': 1})}, + {'value': '48', 'label': L('X_days', {'count': 2})}, + {'value': '72', 'label': L('X_days', {'count': 3})}, + {'value': '120', 'label': L('X_days', {'count': 5})}, + {'value': '168', 'label': L('X_days', {'count': 7})}, + {'value': '336', 'label': L('X_days', {'count': 14})} + ]) }} + {{ form.checkBox('forcePasswordChange', '', L('admin_user_ban_force_password_change'), false) }} + {% else %} + {{ form.radioGroup('status', '', [ + {'value': 'NONE', 'label': L('admin_user_no_restrictions'), 'selected': status=='NONE', 'skip': hideNone}, + {'value': 'SUSPENDED', 'label': L('admin_user_suspend'), 'explanation': L('admin_user_foreign_suspend_explain'), 'selected': status=='SUSPENDED'}, + {'value': 'HIDDEN', 'label': L('admin_user_hide'), 'explanation': L('admin_user_hide_explain'), 'selected': status=='HIDDEN'} + ], {'required': true}) }} + {% endif %} + {% if deleteReportContent %} + {{ form.checkBox('confirmReportContentDeletion', '', L('report_delete_content_checkbox'), false, {'explanation': L('report_delete_content_checkbox_explanation'), 'required': true}) }} + {% endif %} +{{ form.end() }} diff --git a/src/main/resources/templates/common/admin_users_info.twig b/src/main/resources/templates/common/admin_users_info.twig new file mode 100644 index 00000000..3c97dcd6 --- /dev/null +++ b/src/main/resources/templates/common/admin_users_info.twig @@ -0,0 +1,139 @@ +{# @pebvariable name="user" type="smithereen.model.User" #} +{# @pebvariable name="inviter" type="smithereen.model.User" #} +{# @pebvariable name="account" type="smithereen.model.Account" #} +{# @pebvariable name="relationshipMetrics" type="smithereen.model.viewmodel.UserRelationshipMetrics" #} +{# @pebvariable name="contentMetrics" type="smithereen.model.viewmodel.UserContentMetrics" #} +{% extends "page" %} +{% block content %} +{% include "admin_users_info_tabbar" with {'tab': 'info'} %} +
+
+
{{ user | pictureForAvatar('m') }}
+
+

{{ user | name('complete') }}

+
+ @{{ user.fullUsername }}, ID {{ user.id }} + {% if account is not null %} + ({{ account.id }}) + {% else %} + ({{ user.activityPubID }}) + {% endif %} +
+ +
+ {{ L('X_posts', {'count': contentMetrics.postCount}) }} | {{ L('X_comments', {'count': contentMetrics.commentCount}) }} +
+
+
+
+ {% if account is not null %} +
{{ L('email') }}:
+
+ {{- account.email }} + {% if userPermissions.hasPermission('MANAGE_USER_ACCESS') %} + [ {{ L('change') }} ] + {% endif %} + [ {{ L('admin_others_with_this_domain') }} ] +
+
{{ L('role') }}:
+
+ {%- if account.roleID!=0 %}{{ roleTitle }}{% else %}{{ L('role_none') }}{% endif -%} + {%- if userPermissions.hasPermission('MANAGE_ROLES') and user.id!=currentUser.id %} + [ {{ L('change') }} ] + {%- endif -%} +
+
{{ L('signup_date') }}:
+
{{ LD(account.createdAt) }}
+
{{ L('admin_account_activation_status') }}:
+
+ {%- if account.activationInfo is null %} + {{- L('admin_account_activated') -}} + {% elseif account.activationInfo.emailState=='NOT_CONFIRMED' %} + {{- L('admin_account_email_unconfirmed') }} + {% if userPermissions.hasPermission('MANAGE_USER_ACCESS') %} + [ {{ L('admin_activate_account') }} ] + {% endif %} + {% elseif account.activationInfo.emailState=='CHANGE_PENDING' %} + {{- L('admin_account_email_change_pending', {'newEmail': account.activationInfo.newEmail}) }} + {% endif -%} +
+ {% if inviter is not null %} +
{{ L('invited_by') }}:
+ + {% endif %} + {% else %} +
{{ L('admin_actor_last_updated') }}:
+
{{ LD(user.lastUpdated) }}
+ {% endif %} +
+ {% if account is null %} + + {% endif %} + {% if account is not null %} +

{{ L('settings_sessions') }}

+
+ + + + + + {% if userPermissions.hasPermission('MANAGE_USER_ACCESS') %} + + {% endif %} + + {% for session in sessions %} + + + + + {% if userPermissions.hasPermission('MANAGE_USER_ACCESS') %} + + {% endif %} + + {% endfor %} +
{{ L('settings_activity_access_type') }}{{ L('settings_activity_access_time') }}{{ L('ip_address') }}{{ L('actions') }}
+ {% if session.userAgent is empty %} + {{ L('unknown_browser') }} + {% else %} + {{ L('settings_activity_web', {'browserName': session.browserInfo.name, 'browserVersion': session.browserInfo.majorVersion, 'osName': session.browserInfo.os.displayName}) }} + {% endif %} + {{ LD(session.lastActive) }}{{ session.ip.hostAddress }}{{ L('admin_end_session') }}
+
+ {% endif %} +

{{ L('admin_user_restrictions') }}

+
+ {% if user.banStatus=='NONE' %} + {{ L('admin_user_state_no_restrictions') }} + {% elseif user.banStatus=='FROZEN' %} + {{ L('admin_user_state_frozen', {'expirationTime': LD(user.banInfo.expiresAt)}) }} + {% elseif user.banStatus=='SUSPENDED' %} + {% if user.domain is empty %} + {{ L('admin_user_state_suspended', {'deletionTime': LD(accountDeletionTime)}) }} + {% else %} + {{ L('admin_user_state_suspended_foreign') }} + {% endif %} + {% elseif user.banStatus=='HIDDEN' %} + {{ L('admin_user_state_hidden') }} + {% elseif user.banStatus=='SELF_DEACTIVATED' %} + {{ L('admin_user_state_self_deactivated', {'deletionTime': LD(accountDeletionTime)}) }} + {% endif %} + {% if user.banInfo is not null %} +
{% if banModerator is not null %}{{ banModerator | name }}, {% endif %}{{ LD(user.banInfo.bannedAt()) }} + {% if user.banInfo.message is not empty %}
{{ L('admin_user_ban_message') }}: {{ user.banInfo.message }}{% endif %} + {% endif %} +
+ {{ L('admin_user_change_restrictions') }} + {% if userPermissions.hasPermission('DELETE_USERS_IMMEDIATE') and (user.banStatus=='SUSPENDED' or user.banStatus=='SELF_DEACTIVATED') %} + {{ L('admin_user_delete_account_now') }} + {% endif %} +
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_users_info_tabbar.twig b/src/main/resources/templates/common/admin_users_info_tabbar.twig new file mode 100644 index 00000000..02970947 --- /dev/null +++ b/src/main/resources/templates/common/admin_users_info_tabbar.twig @@ -0,0 +1,13 @@ +{# @pebvariable name="userPermissions" type="smithereen.model.UserPermissions" #} +
+ {% if userPermissions.hasPermission('MANAGE_USERS') %} + {{ L('admin_manage_user') }} + {{ L('admin_user_staff_notes') }}{% if staffNoteCount>0 %} ({{ staffNoteCount | numberformat }}){% endif %} + {% endif %} + {% if userPermissions.hasPermission('MANAGE_REPORTS') %} + {{ L('menu_reports') }} + {% endif %} + {% if userPermissions.hasPermission('VIEW_SERVER_AUDIT_LOG') %} + {{ L('admin_audit_log') }} + {% endif %} +
\ No newline at end of file diff --git a/src/main/resources/templates/common/admin_users_notes.twig b/src/main/resources/templates/common/admin_users_notes.twig new file mode 100644 index 00000000..8247cad2 --- /dev/null +++ b/src/main/resources/templates/common/admin_users_notes.twig @@ -0,0 +1,37 @@ +{# @pebvariable name="items" type="java.util.List" #} +{% extends "page" %} +{% block content %} +{% include "admin_users_info_tabbar" with {'tab': 'staffNotes'} %} +{% if not mobile %} +
+
{{ L('admin_user_X_staff_notes_summary', {'count': totalItems}) }}
+ {% include "pagination" %} +
+{% endif %} +
+ {% for note in items %} + + {% else %} +
{{ L('admin_user_staff_notes_empty') }}
+ {% endfor %} +
+
{% include "pagination" %}
+
+
+
+ + {% script %} + autoSizeTextArea(ge("commentText")); + addSendOnCtrlEnter(ge("commentText")); + {% endscript %} +
+
+{% endblock %} + diff --git a/src/main/resources/templates/common/admin_users_role.twig b/src/main/resources/templates/common/admin_users_role.twig new file mode 100644 index 00000000..1a753b8f --- /dev/null +++ b/src/main/resources/templates/common/admin_users_role.twig @@ -0,0 +1,8 @@ +

{{L('choose_role_for_X', {'name': targetAccount.user.firstLastAndGender})}}

+ + \ No newline at end of file diff --git a/src/main/resources/templates/common/admin_users_tabbar.twig b/src/main/resources/templates/common/admin_users_tabbar.twig new file mode 100644 index 00000000..2d89e5d6 --- /dev/null +++ b/src/main/resources/templates/common/admin_users_tabbar.twig @@ -0,0 +1,9 @@ +{# @pebvariable name="userPermissions" type="smithereen.model.UserPermissions" #} +
+ {% if userPermissions.hasPermission('MANAGE_USERS') %} + {{L('admin_users')}} + {% endif %} + {% if userPermissions.hasPermission('MANAGE_INVITES') %} + {{ L('admin_invites') }} + {% endif %} +
\ No newline at end of file diff --git a/src/main/resources/templates/common/deactivate_account_form.twig b/src/main/resources/templates/common/deactivate_account_form.twig new file mode 100644 index 00000000..4d62cfe4 --- /dev/null +++ b/src/main/resources/templates/common/deactivate_account_form.twig @@ -0,0 +1,5 @@ +{% import "forms" as form %} +

{{ L('settings_deactivate_confirm', {'timeInterval': L('X_days', {'count': 30})}) }}

+{{ form.start("deactivateAccount") }} + {{ form.textInput('password', L('password'), email, {'type': 'password', 'required': true}) }} +{{ form.end() }} diff --git a/src/main/resources/templates/common/email_confirmation_code_form.twig b/src/main/resources/templates/common/email_confirmation_code_form.twig new file mode 100644 index 00000000..ee596d1c --- /dev/null +++ b/src/main/resources/templates/common/email_confirmation_code_form.twig @@ -0,0 +1,4 @@ +
{{ L('action_confirmation_text', {'maskedEmail': maskedEmail, 'action': L(action)}) }}
+
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/common/hidden_profile.twig b/src/main/resources/templates/common/hidden_profile.twig new file mode 100644 index 00000000..7483719b --- /dev/null +++ b/src/main/resources/templates/common/hidden_profile.twig @@ -0,0 +1,11 @@ +{% extends "page" %} +{% block content %} +
+

{{ L('profile_hidden') }}

+

{{ L('profile_hidden_info') }}

+

+ {{ L('log_in') }} + {% if user.domain is not empty %}| {{ L('open_on_server_X', {'domain': user.domain}) }}{% endif %} +

+
+{% endblock %} \ No newline at end of file diff --git a/src/main/resources/templates/common/left_menu.twig b/src/main/resources/templates/common/left_menu.twig index 31f7d4a8..cedac5d9 100644 --- a/src/main/resources/templates/common/left_menu.twig +++ b/src/main/resources/templates/common/left_menu.twig @@ -14,11 +14,13 @@
  • {{L('menu_news')}}
  • {{L('menu_notifications')}}{{ menuCounter(userNotifications.newNotificationsCount) }}
  • {{L('menu_settings')}}
  • - {% if userPermissions.serverAccessLevel.ordinal>=2 %} + {% if userPermissions.role is not null %}
  • - {% if userPermissions.serverAccessLevel.ordinal>=3 %}
  • {{L('menu_admin')}}
  • {% endif %} - {% if serverSignupMode=='MANUAL_APPROVAL' or adminNotifications.signupRequestsCount>0 %}
  • {{ L('menu_signup_requests') }}{{ menuCounter(adminNotifications.signupRequestsCount) }}
  • {% endif %} -
  • {{ L('menu_reports') }}{{ menuCounter(adminNotifications.openReportsCount) }}
  • + {% if userPermissions.hasAnyPermission(['MANAGE_SERVER_SETTINGS', 'MANAGE_SERVER_RULES', 'MANAGE_ROLES', 'VIEW_SERVER_AUDIT_LOG']) %}
  • {{L('menu_admin')}}
  • {% endif %} + {% if userPermissions.hasAnyPermission(['MANAGE_USERS', 'MANAGE_INVITES']) %}
  • {{ L('menu_users') }}
  • {% endif %} + {% if userPermissions.hasPermission('MANAGE_INVITES') and (serverSignupMode=='MANUAL_APPROVAL' or adminNotifications.signupRequestsCount>0) %}
  • {{ L('menu_signup_requests') }}{{ menuCounter(adminNotifications.signupRequestsCount) }}
  • {% endif %} + {% if userPermissions.hasPermission('MANAGE_REPORTS') %}
  • {{ L('menu_reports') }}{{ menuCounter(adminNotifications.openReportsCount) }}
  • {% endif %} + {% if userPermissions.hasAnyPermission(['MANAGE_FEDERATION', 'MANAGE_BLOCKING_RULES']) %}
  • {{ L('menu_access') }}
  • {% endif %} {% endif %} {% else %}
  • {{ L('log_in') }}
  • diff --git a/src/main/resources/templates/common/reactivate_account_form.twig b/src/main/resources/templates/common/reactivate_account_form.twig new file mode 100644 index 00000000..21cbb3b5 --- /dev/null +++ b/src/main/resources/templates/common/reactivate_account_form.twig @@ -0,0 +1,5 @@ +{% import "forms" as form %} +

    {{ L('settings_reactivate_confirm') }}

    +{{ form.start("deactivateAccount") }} + {{ form.textInput('password', L('password'), email, {'type': 'password', 'required': true}) }} +{{ form.end() }} diff --git a/src/main/resources/templates/common/register_form.twig b/src/main/resources/templates/common/register_form.twig index 51f6a7bc..179a9530 100644 --- a/src/main/resources/templates/common/register_form.twig +++ b/src/main/resources/templates/common/register_form.twig @@ -1,5 +1,5 @@ {% import "forms" as form %} -
    + {% if preFilledInvite is not null %} {% endif %} @@ -8,14 +8,16 @@ {{ form.textInput('username', L('username'), username, {'maxlength': 50, 'required': true, 'pattern': '^[a-zA-Z][a-zA-Z0-9._-]+$', 'explanation': L('username_explain')}) }} {{ form.textInput('first_name', L('first_name'), first_name, {'maxlength': 100, 'required': true}) }} {{ form.textInput('last_name', L('last_name'), last_name, {'maxlength': 100}) }} + {{ form.textInput('website', 'Website (do not fill in)', '') }} {{ form.textInput('email', L('email'), email, {'type': 'email', 'required': true}) }} {{ form.textInput('password', L('password'), password, {'type': 'password', 'required': true, 'minlength': 4}) }} + {{ form.textInput('passwordConfirm', 'Confirm password (do not fill in)', '', {'type': 'password'}) }} {{ form.textInput('password2', L('password_confirm'), password2, {'type': 'password', 'required': true, 'minlength': 4}) }} {% if signupMode!='OPEN' and preFilledInvite is null %} {{ form.textInput('invite', L('invitation_code'), invite, {'maxlength': 32, 'required': true}) }} {% endif %} {% if captchaSid is not empty %} - {{ form.unlabeledRowStart() }}{{ form.unlabeledRowEnd() }} + {{ form.unlabeledRowStart() }}{{ form.unlabeledRowEnd() }} {{ form.textInput('captcha', L('captcha_label'), null, {'required': true}) }} {% endif %} {{ form.footer(L('register')) }} diff --git a/src/main/resources/templates/common/register_form_request_invite.twig b/src/main/resources/templates/common/register_form_request_invite.twig index a8120fd8..a97e7169 100644 --- a/src/main/resources/templates/common/register_form_request_invite.twig +++ b/src/main/resources/templates/common/register_form_request_invite.twig @@ -1,14 +1,16 @@ {% import "forms" as form %} - +

    {{L('register')}}

    {{ L('manual_signup_approval_explain') }}

    {{ form.start("requestInvite", message) }} {{ form.textInput('first_name', L('first_name'), first_name, {'maxlength': 100, 'required': true}) }} {{ form.textInput('last_name', L('last_name'), last_name, {'maxlength': 100}) }} + {{ form.textInput('website', 'Website (do not fill in)', '') }} {{ form.textInput('email', L('email'), email, {'type': 'email', 'required': true}) }} + {{ form.textInput('password', 'Password (do not fill in)', '', {'type': 'password'}) }} {{ form.textArea('reason', L('request_invitation_reason'), reason, {'required': true, 'explanation': L('request_invitation_reason_explain')}) }} {% if captchaSid is not empty %} - {{ form.unlabeledRowStart() }}{{ form.unlabeledRowEnd() }} + {{ form.unlabeledRowStart() }}{{ form.unlabeledRowEnd() }} {{ form.textInput('captcha', L('captcha_label'), null, {'required': true}) }} {% endif %} {{ form.footer(L('request_invitation')) }} diff --git a/src/main/resources/templates/common/report.twig b/src/main/resources/templates/common/report.twig new file mode 100644 index 00000000..a201ab31 --- /dev/null +++ b/src/main/resources/templates/common/report.twig @@ -0,0 +1,112 @@ +{# @pebvariable name="report" type="smithereen.model.ViolationReport" #} +{% extends "page" %} +{% block content %} +{% include "reports_tabbar" with {'tab': 'view'} %} +
    +
    + {% if report.state=='OPEN' %} +
    {{ L('report_state_open') }}
    + {% elseif report.state=='CLOSED_REJECTED' %} +
    {{ L('report_state_rejected') }}
    + {% elseif report.state=='CLOSED_ACTION_TAKEN' %} +
    {{ L('report_state_resolved') }}
    + {% endif %} + {% if report.targetID>0 %} + {% set user=users[report.targetID] %} + + +
    @{{ user.fullUsername }}
    + {% if userPermissions.hasPermission('MANAGE_USERS') %} + + {% endif %} + {% elseif report.targetID<0 %} + {% set group=groups[-report.targetID] %} + + +
    @{{ group.fullUsername }}
    + {% endif %} +
    +
    + {{ L('report_from') }}: + {% if report.reporterID!=0 %} + {{ users[report.reporterID] | name('complete') }} + {% else %} + {{ L('report_sender_anonymous') }} + {% endif %} +
    +
    + {{ L('report_sent_at') }}: {{ LD(report.time) }} +
    + {% if report.serverDomain is not empty %} +
    + {{ L('server') }}: {{ report.serverDomain }} +
    + {% endif %} + {% if report.actionTime is not null %} +
    + {{ L('report_resolved_at') }}: {{ LD(report.actionTime) }} +
    + {% endif %} + {% if report.comment is not empty %} +
    +
    {{ L('report_comment') }}:
    + {{ report.comment }} +
    + {% endif %} + {% if content is not empty %} +
    +
    {{ L('admin_report_content') }}:
    + {% for cont in content %} +
    + {%- if cont.type=='post' %} + {%- set langKey='admin_report_content_post' %} + {%- set exists=posts contains cont.id %} + {%- elseif cont.type=='comment' %} + {%- set langKey='admin_report_content_comment' %} + {%- set exists=posts contains cont.id %} + {%- elseif cont.type=='message' %} + {%- set langKey='admin_report_content_message' %} + {%- set exists=messages contains cont.id %} + {%- endif %} + {{ L(langKey, {'id': cont.id}) }} +
    + {% endfor %} +
    + {% endif %} +
    +
    + {% for action in actions %} +
    + +
    +
    {{ action.mainTextHtml | raw }}
    + {% if action.extraTextHtml is not empty %}
    {{ action.extraTextHtml | raw }}
    {% endif %} +
    {{ LD(action.action.time) }}
    +
    +
    + {% else %} +
    {{ L('admin_report_no_actions') }}
    + {% endfor %} +
    +
    + {% if report.state=='OPEN' %} + {{ L('report_action_reject') }} + {% if canDeleteContent %}{{ L(isLocalTarget ? 'report_action_delete_content' : 'report_action_delete_content_locally') }}{% endif %} + {% if report.targetID>0 %}{{ L('report_action_limit_user') }}{% endif %} + {% if canDeleteContent and report.targetID>0 %}{{ L('report_action_delete_and_limit') }}{% endif %} + {% else %} + {{ L('mark_report_unresolved') }} + {% endif %} +
    +
    + +
    + + {% script %} + autoSizeTextArea(ge("commentText")); + addSendOnCtrlEnter(ge("commentText")); + {% endscript %} + +
    +{% endblock %} + diff --git a/src/main/resources/templates/common/report_list.twig b/src/main/resources/templates/common/report_list.twig index 9fff1256..f205aa47 100644 --- a/src/main/resources/templates/common/report_list.twig +++ b/src/main/resources/templates/common/report_list.twig @@ -1,36 +1,44 @@ {# @pebvariable name="items" type="smithereen.model.ViolationReport[]" #} {% extends "page" %} {% block content %} - +{% if filteredByUser is not null %} +{% include "reports_user_tabbar" with {'user': filteredByUser} %} +{% else %} +{% include "reports_tabbar" %} +{% endif %} {% if not isMobile %}
    {{ L('summary_X_reports', {'count': totalItems}) }}
    {% include "pagination" %}
    {% endif %} -
    +
    {% for report in items %} -
    +
    - {% if report.targetType=='USER' %} + {% if report.state=='OPEN' %} +
    {{ L('report_state_open') }}
    + {% elseif report.state=='CLOSED_REJECTED' %} +
    {{ L('report_state_rejected') }}
    + {% elseif report.state=='CLOSED_ACTION_TAKEN' %} +
    {{ L('report_state_resolved') }}
    + {% endif %} + {% if report.targetID>0 %} {% set user=users[report.targetID] %} -
    - +
    {{ user | pictureForAvatar('s') }}
    +
    {{ user | name('complete') }}
    @{{ user.fullUsername }}
    - {% elseif report.targetType=='GROUP' %} - {% set group=groups[report.targetID] %} - - + {% elseif report.targetID<0 %} + {% set group=groups[-report.targetID] %} +
    {{ group | pictureForAvatar('s') }}
    +
    {{ group.name }}
    @{{ group.fullUsername }}
    {% endif %}
    {{ L('report_from') }}: {% if report.reporterID!=0 %} - {{ users[report.reporterID] | name('complete') }} + {{ users[report.reporterID] | name('complete') }} {% else %} {{ L('report_sender_anonymous') }} {% endif %} @@ -54,58 +62,11 @@ {{ report.comment }}
    {% endif %} - {% if report.contentType=='POST' %} -
    - {% set post=posts[report.contentID] %} - {% if post is not null %} -
    {{ L(post.replyLevel>0 ? 'content_type_comment' : 'content_type_post') }}: {{ LD(post.createdAt) }}
    - {{ users[post.authorID] | name('full') }} - {% if post.authorID!=post.ownerID %}» {{ (post.ownerID>0 ? users[post.ownerID] : groups[-post.ownerID]) | name }}{% endif %} - {% if post.text is not empty %}{{ post.text | stripHTML | truncateText }}{% endif %} - {% if post.attachments is not empty %} -
    [ {{ describeAttachments(post.processedAttachments) }} ]
    - {% endif %} - {% if post.hasContentWarning %} -
    [ {{ L('post_form_cw') }}: {{ post.contentWarning }} ]
    - {% endif %} - {% else %} - {{ L('post_deleted_placeholder') }} - {% endif %} -
    - {% elseif report.contentType=='MESSAGE' %} -
    - {% set msg=messages[report.contentID] %} - {% if msg is not null %} -
    {{ L('content_type_message') }}: {{ LD(msg.createdAt) }}
    - {{ users[msg.senderID] | name }} - {% if msg.text is not empty %}{{ msg.text | stripHTML | truncateText }}{% endif %} - {% if msg.attachments is not empty %} -
    [ {{ describeAttachments(msg.attachments) }} ]
    - {% endif %} - {% else %} - {{ L('post_deleted_placeholder') }} - {% endif %} -
    - {% endif %} - {% if report.actionTime is null %} -
    - {% if report.actionTime is null %}{% endif %} - {% if report.contentType=='POST' %} - - {% if not posts[report.contentID].hasContentWarning %}{% endif %} - {% endif %} - {# #} -
    - {% endif %} -
    + {% else %} +
    {{ L('no_reports') }}
    +
    {% endfor %}
    {% include "pagination" %}
    diff --git a/src/main/resources/templates/common/reports_tabbar.twig b/src/main/resources/templates/common/reports_tabbar.twig new file mode 100644 index 00000000..f52da5d5 --- /dev/null +++ b/src/main/resources/templates/common/reports_tabbar.twig @@ -0,0 +1,5 @@ + diff --git a/src/main/resources/templates/common/reports_user_tabbar.twig b/src/main/resources/templates/common/reports_user_tabbar.twig new file mode 100644 index 00000000..58e43aed --- /dev/null +++ b/src/main/resources/templates/common/reports_user_tabbar.twig @@ -0,0 +1,5 @@ +{% include "admin_users_info_tabbar" with {'tab': 'reports'} %} + \ No newline at end of file diff --git a/src/main/resources/templates/common/settings.twig b/src/main/resources/templates/common/settings.twig index 5aeb73a0..ccbe147a 100644 --- a/src/main/resources/templates/common/settings.twig +++ b/src/main/resources/templates/common/settings.twig @@ -36,4 +36,7 @@ {{ form.end() }}
    +
    +
    {{ L('settings_deactivate_account', {}, {'deactivate': {'href': '/settings/deactivateAccountForm', 'data-ajax-box': '1'} }) }}
    +
    {%endblock%} \ No newline at end of file diff --git a/src/main/resources/templates/desktop/admin_server_list.twig b/src/main/resources/templates/desktop/admin_server_list.twig index ea7278ba..4b205078 100644 --- a/src/main/resources/templates/desktop/admin_server_list.twig +++ b/src/main/resources/templates/desktop/admin_server_list.twig @@ -1,6 +1,6 @@ {% extends "page" %} {% block content %} -{% include "admin_tabbar" with {'tab': 'federation'} %} +{% include "admin_access_tabbar" with {'tab': 'federation'} %}
    diff --git a/src/main/resources/templates/desktop/forms.twig b/src/main/resources/templates/desktop/forms.twig index dab8ed9d..6a1c47db 100644 --- a/src/main/resources/templates/desktop/forms.twig +++ b/src/main/resources/templates/desktop/forms.twig @@ -33,7 +33,8 @@ {%- if options.autocomplete==false %} autocomplete="off"{% endif %} {%- if options.pattern is not empty %} pattern="{{ options.pattern }}"{% endif %} {%- if options.min is not null %} min="{{options.min}}"{%endif%} - {%- if options.max is not null %} max="{{ options.max }}"{%endif%}/> + {%- if options.max is not null %} max="{{ options.max }}"{%endif%} + {%- if options.placeholder is not null %} placeholder="{{ options.placeholder }}"{% endif %}/> {% if options.prefix is not empty %}
    {% endif %} @@ -113,12 +114,14 @@ autoSizeTextArea(ge("{{ name }}")); {% for opt in selectOptions %} + {% if not opt.skip %}
    - +
    {% if opt.explanation is not empty %} {{ opt.explanation }} {% endif %} + {% endif %} {% endfor %} @@ -132,8 +135,8 @@ autoSizeTextArea(ge("{{ name }}")); {% endif %} -
    - +
    +
    {% if options.explanation is not empty %} {{ options.explanation }} diff --git a/src/main/resources/templates/desktop/friends_row.twig b/src/main/resources/templates/desktop/friends_row.twig index 9ae0d3e0..0b02c2e8 100644 --- a/src/main/resources/templates/desktop/friends_row.twig +++ b/src/main/resources/templates/desktop/friends_row.twig @@ -2,12 +2,12 @@
    - {{friend | pictureForAvatar('m')}} + {{friend | pictureForAvatar('m')}}
    {{ L('name') }}:
    - + {% if friend.domain is not empty %}
    {{ L('server') }}:
    {{ friend.domain }}
    diff --git a/src/main/resources/templates/desktop/group.twig b/src/main/resources/templates/desktop/group.twig index 83823ff0..bfa42631 100644 --- a/src/main/resources/templates/desktop/group.twig +++ b/src/main/resources/templates/desktop/group.twig @@ -104,7 +104,7 @@
  • {{ L('report') }}
  • {% endif %} {% if currentUser is not null %} - {% if userPermissions.serverAccessLevel.ordinal>=3 and group.domain is not empty %} + {% if userPermissions.hasPermission('MANAGE_GROUPS') and group.domain is not empty %}
  • [A] {{ L('sync_profile') }}
  • [A] {{ L('sync_members') }}
  • diff --git a/src/main/resources/templates/desktop/groups.twig b/src/main/resources/templates/desktop/groups.twig index 1bdab2d2..638c94aa 100644 --- a/src/main/resources/templates/desktop/groups.twig +++ b/src/main/resources/templates/desktop/groups.twig @@ -25,12 +25,12 @@
    - {{group | pictureForAvatar('m')}} + {{group | pictureForAvatar('m')}}
    {{ L('group_name') }}:
    - +
    {{ L('group_size') }}:
    {{ L('X_members', {'count': group.memberCount}) }}
    {% if group.event %} diff --git a/src/main/resources/templates/desktop/like_popover.twig b/src/main/resources/templates/desktop/like_popover.twig index fd431c2d..865dc977 100644 --- a/src/main/resources/templates/desktop/like_popover.twig +++ b/src/main/resources/templates/desktop/like_popover.twig @@ -3,6 +3,6 @@ {{currentUser | pictureForAvatar('s', 32)}} {%endif%} {%for user in users%} - {{user | pictureForAvatar('s', 32)}} + {{user | pictureForAvatar('s', 32)}} {%endfor%}
    \ No newline at end of file diff --git a/src/main/resources/templates/desktop/notifications.twig b/src/main/resources/templates/desktop/notifications.twig index ca18709f..2a1a3b20 100644 --- a/src/main/resources/templates/desktop/notifications.twig +++ b/src/main/resources/templates/desktop/notifications.twig @@ -13,7 +13,7 @@ {%if notification.objectID!=0%} {%set object=posts[notification.objectID]%} {%set isComment=object is not null ? object.replyLevel>0 : false%} -{% set objectOwner=object.ownerID>0 ? users[object.ownerID] : groups[-object.ownerID] %} +{% if object is not null %}{% set objectOwner=object.ownerID>0 ? users[object.ownerID] : groups[-object.ownerID] %}{% endif %} {%else%} {%set object=null%} {%endif%} diff --git a/src/main/resources/templates/desktop/page.twig b/src/main/resources/templates/desktop/page.twig index 8b42dbeb..a816bcc9 100644 --- a/src/main/resources/templates/desktop/page.twig +++ b/src/main/resources/templates/desktop/page.twig @@ -1,5 +1,5 @@ - + {{ title }} @@ -17,7 +17,7 @@ {%endfor%} {% endif %} {% if noindex %} - + {% endif %}