From 29f09435b2e3f84dad5f74e16498a6f427c961ec Mon Sep 17 00:00:00 2001 From: Grishka Date: Fri, 3 Jun 2022 23:32:57 +0300 Subject: [PATCH 1/6] Fix thread sync for external media downloads --- .../java/smithereen/routes/SystemRoutes.java | 58 ++++++----- .../smithereen/util/NamedMutexCollection.java | 60 ++++++++++++ .../smithereen/NamedMutexCollectionTest.java | 96 +++++++++++++++++++ 3 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 src/main/java/smithereen/util/NamedMutexCollection.java create mode 100644 src/test/java/smithereen/NamedMutexCollectionTest.java diff --git a/src/main/java/smithereen/routes/SystemRoutes.java b/src/main/java/smithereen/routes/SystemRoutes.java index 5488e700..12f06be9 100644 --- a/src/main/java/smithereen/routes/SystemRoutes.java +++ b/src/main/java/smithereen/routes/SystemRoutes.java @@ -62,6 +62,7 @@ import smithereen.templates.RenderedTemplateResponse; import smithereen.util.BlurHash; import smithereen.util.JsonObjectBuilder; +import smithereen.util.NamedMutexCollection; import spark.Request; import spark.Response; import spark.utils.StringUtils; @@ -70,6 +71,7 @@ public class SystemRoutes{ private static final Logger LOG=LoggerFactory.getLogger(SystemRoutes.class); + private static final NamedMutexCollection downloadMutex=new NamedMutexCollection(); public static Object downloadExternalMedia(Request req, Response resp) throws SQLException{ requireQueryParams(req, "type", "format", "size"); @@ -174,35 +176,41 @@ public static Object downloadExternalMedia(Request req, Response resp) throws SQ } if(uri!=null){ - MediaCache.Item existing=cache.get(uri); - if(mime.startsWith("image/")){ - if(existing!=null){ - resp.redirect(new CachedRemoteImage((MediaCache.PhotoItem) existing, cropRegion).getUriForSizeAndFormat(sizeType, format).toString()); - return ""; - } - try{ - MediaCache.PhotoItem item=(MediaCache.PhotoItem) cache.downloadAndPut(uri, mime, itemType); - if(item==null){ - if(itemType==MediaCache.ItemType.AVATAR && req.queryParams("retrying")==null){ - if(user!=null){ - ForeignUser updatedUser=context(req).getObjectLinkResolver().resolve(user.activityPubID, ForeignUser.class, true, true, true); - resp.redirect(Config.localURI("/system/downloadExternalMedia?type=user_ava&user_id="+updatedUser.id+"&size="+sizeType.suffix()+"&format="+format.fileExtension()+"&retrying").toString()); - return ""; - }else if(group!=null){ - ForeignGroup updatedGroup=context(req).getObjectLinkResolver().resolve(group.activityPubID, ForeignGroup.class, true, true, true); - resp.redirect(Config.localURI("/system/downloadExternalMedia?type=group_ava&user_id="+updatedGroup.id+"&size="+sizeType.suffix()+"&format="+format.fileExtension()+"&retrying").toString()); - return ""; + final String uriStr=uri.toString(); + downloadMutex.acquire(uriStr); + try{ + MediaCache.Item existing=cache.get(uri); + if(mime.startsWith("image/")){ + if(existing!=null){ + resp.redirect(new CachedRemoteImage((MediaCache.PhotoItem) existing, cropRegion).getUriForSizeAndFormat(sizeType, format).toString()); + return ""; + } + try{ + MediaCache.PhotoItem item=(MediaCache.PhotoItem) cache.downloadAndPut(uri, mime, itemType); + if(item==null){ + if(itemType==MediaCache.ItemType.AVATAR && req.queryParams("retrying")==null){ + if(user!=null){ + ForeignUser updatedUser=context(req).getObjectLinkResolver().resolve(user.activityPubID, ForeignUser.class, true, true, true); + resp.redirect(Config.localURI("/system/downloadExternalMedia?type=user_ava&user_id="+updatedUser.id+"&size="+sizeType.suffix()+"&format="+format.fileExtension()+"&retrying").toString()); + return ""; + }else if(group!=null){ + ForeignGroup updatedGroup=context(req).getObjectLinkResolver().resolve(group.activityPubID, ForeignGroup.class, true, true, true); + resp.redirect(Config.localURI("/system/downloadExternalMedia?type=group_ava&user_id="+updatedGroup.id+"&size="+sizeType.suffix()+"&format="+format.fileExtension()+"&retrying").toString()); + return ""; + } } + resp.redirect(uri.toString()); + }else{ + resp.redirect(new CachedRemoteImage(item, cropRegion).getUriForSizeAndFormat(sizeType, format).toString()); } - resp.redirect(uri.toString()); - }else{ - resp.redirect(new CachedRemoteImage(item, cropRegion).getUriForSizeAndFormat(sizeType, format).toString()); + return ""; + }catch(IOException x){ + LOG.warn("Exception while downloading external media file from {}", uri, x); } - return ""; - }catch(IOException x){ - LOG.warn("Exception while downloading external media file from {}", uri, x); + resp.redirect(uri.toString()); } - resp.redirect(uri.toString()); + }finally{ + downloadMutex.release(uriStr); } } return ""; diff --git a/src/main/java/smithereen/util/NamedMutexCollection.java b/src/main/java/smithereen/util/NamedMutexCollection.java new file mode 100644 index 00000000..507b4136 --- /dev/null +++ b/src/main/java/smithereen/util/NamedMutexCollection.java @@ -0,0 +1,60 @@ +package smithereen.util; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A collection of named mutexes. + * Useful for when you want only one copy of some task to run at a time. + * For example, when downloading files in response to a user request, you want any other potential tasks to download the same file to wait + * for the first one to complete, and then use the already downloaded file rather than download it multiple times in parallel. + */ +public class NamedMutexCollection{ + private final LinkedList reusePool=new LinkedList<>(); + private final HashMap heldLocks=new HashMap<>(); + + public void acquire(String name){ + ReentrantLock lock; + synchronized(this){ + lock=heldLocks.computeIfAbsent(name, k->reusePool.isEmpty() ? new RefCountedReentrantLock() : reusePool.pop()); + } + lock.lock(); + } + + public synchronized void release(String name){ + RefCountedReentrantLock lock=heldLocks.get(name); + if(lock==null) + throw new IllegalStateException("Mutex for name '"+name+"' not held"); + lock.unlock(); + if(lock.getRefCount()==0){ + heldLocks.remove(name); + reusePool.push(lock); + } + } + + public synchronized int getHeldLockCount(){ + return heldLocks.size(); + } + + private static class RefCountedReentrantLock extends ReentrantLock{ + private AtomicInteger refCount=new AtomicInteger(); + + @Override + public void lock(){ + refCount.incrementAndGet(); + super.lock(); + } + + @Override + public void unlock(){ + super.unlock(); + refCount.decrementAndGet(); + } + + public int getRefCount(){ + return refCount.get(); + } + } +} diff --git a/src/test/java/smithereen/NamedMutexCollectionTest.java b/src/test/java/smithereen/NamedMutexCollectionTest.java new file mode 100644 index 00000000..991e1a04 --- /dev/null +++ b/src/test/java/smithereen/NamedMutexCollectionTest.java @@ -0,0 +1,96 @@ +package smithereen; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +import smithereen.util.NamedMutexCollection; + +public class NamedMutexCollectionTest{ + + @Test + public void testSingleThread(){ + NamedMutexCollection mutex=new NamedMutexCollection(); + assertTimeoutPreemptively(Duration.ofSeconds(2), ()->{ + CounterTask task=new CounterTask(mutex, "test"); + Thread thread=new Thread(task); + thread.start(); + thread.join(); + assertEquals(1, task.count); + assertEquals(0, mutex.getHeldLockCount()); + }); + } + + @Test + public void testMultipleStaggeredThreads(){ + NamedMutexCollection mutex=new NamedMutexCollection(); + assertTimeoutPreemptively(Duration.ofSeconds(10), ()->{ + CounterTask task=new CounterTask(mutex, "test"); + ArrayList threads=new ArrayList<>(10); + for(int i=0;i<10;i++){ + Thread thread=new Thread(task, "TaskThread-"+i); + threads.add(thread); + try{Thread.sleep(100);}catch(InterruptedException ignore){} + thread.start(); + } + for(Thread t:threads){ + t.join(); + } + assertEquals(1, task.count); + assertEquals(0, mutex.getHeldLockCount()); + }); + } + + @Test + public void testMultipleStaggeredThreadsMultipleNames(){ + NamedMutexCollection mutex=new NamedMutexCollection(); + assertTimeoutPreemptively(Duration.ofSeconds(10), ()->{ + CounterTask task1=new CounterTask(mutex, "test1"); + CounterTask task2=new CounterTask(mutex, "test2"); + ArrayList threads=new ArrayList<>(10); + for(int i=0;i<20;i++){ + Thread thread=new Thread(i%2==1 ? task1 : task2, "TaskThread-"+i); + threads.add(thread); + try{Thread.sleep(50);}catch(InterruptedException ignore){} + thread.start(); + } + for(Thread t:threads){ + t.join(); + } + assertEquals(1, task1.count); + assertEquals(1, task2.count); + assertEquals(0, mutex.getHeldLockCount()); + }); + } + + private static class CounterTask implements Runnable{ + private final NamedMutexCollection mutex; + private final String name; + private int count=0; + + private CounterTask(NamedMutexCollection mutex, String name){ + this.mutex=mutex; + this.name=name; + } + + @Override + public void run(){ + System.out.println("Starting thread "+Thread.currentThread().getName()); + mutex.acquire(name); + System.out.println("Thread "+Thread.currentThread().getName()+" acquired mutex"); + try{ + try{Thread.sleep(500);}catch(InterruptedException ignore){} + if(count==0){ + try{Thread.sleep(500);}catch(InterruptedException ignore){} + count++; + } + }finally{ + mutex.release(name); + } + System.out.println("Exiting thread "+Thread.currentThread().getName()); + } + } +} From f6140a269fce9e2c5d0b73464205165f7e85d894 Mon Sep 17 00:00:00 2001 From: Grishka Date: Thu, 16 Jun 2022 06:00:32 +0300 Subject: [PATCH 2/6] Email confirmation --- src/main/java/smithereen/Config.java | 2 + src/main/java/smithereen/Mailer.java | 62 ++++++++++++ .../smithereen/SmithereenApplication.java | 11 ++- src/main/java/smithereen/Utils.java | 43 ++++++++- src/main/java/smithereen/data/Account.java | 36 ++++++- .../smithereen/data/WebDeltaResponse.java | 12 +++ .../java/smithereen/routes/SessionRoutes.java | 94 ++++++++++++++++++- .../routes/SettingsAdminRoutes.java | 38 +++++++- .../smithereen/routes/SettingsRoutes.java | 75 +++++++++++++++ .../storage/DatabaseSchemaUpdater.java | 3 +- .../smithereen/storage/SessionStorage.java | 16 ++++ .../smithereen/templates/LangFunction.java | 31 +----- .../java/smithereen/templates/Templates.java | 2 +- .../java/smithereen/util/FloodControl.java | 1 + src/main/resources/langs/en.json | 37 +++++++- src/main/resources/langs/ru.json | 33 ++++++- .../templates/common/admin_server_info.twig | 1 + .../templates/common/admin_users.twig | 3 + .../templates/common/change_email_form.twig | 4 + .../common/email_confirm_required.twig | 15 +++ .../common/email_confirm_success.twig | 18 ++++ .../templates/common/generic_message.twig | 2 +- .../resources/templates/common/settings.twig | 12 +++ .../resources/templates/desktop/forms.twig | 31 ++++++ .../templates/email/activate_account.twig | 8 ++ .../templates/email/update_email_new.twig | 8 ++ .../templates/email/update_email_old.twig | 5 + .../resources/templates/mobile/forms.twig | 28 ++++++ src/main/web/common_ts/Helpers.ts | 13 ++- src/main/web/desktop.scss | 30 ++++++ src/main/web/mobile.scss | 18 ++++ 31 files changed, 644 insertions(+), 48 deletions(-) create mode 100644 src/main/resources/templates/common/change_email_form.twig create mode 100644 src/main/resources/templates/common/email_confirm_required.twig create mode 100644 src/main/resources/templates/common/email_confirm_success.twig create mode 100644 src/main/resources/templates/email/activate_account.twig create mode 100644 src/main/resources/templates/email/update_email_new.twig create mode 100644 src/main/resources/templates/email/update_email_old.twig diff --git a/src/main/java/smithereen/Config.java b/src/main/java/smithereen/Config.java index 6c609aa0..30f8d889 100644 --- a/src/main/java/smithereen/Config.java +++ b/src/main/java/smithereen/Config.java @@ -69,6 +69,7 @@ public class Config{ public static String serverPolicy; public static String serverAdminEmail; public static SignupMode signupMode=SignupMode.CLOSED; + public static boolean signupConfirmEmail; public static String mailFrom; public static String smtpServerAddress; @@ -136,6 +137,7 @@ public static void loadFromDatabase() throws SQLException{ signupMode=SignupMode.valueOf(_signupMode); }catch(IllegalArgumentException ignore){} } + signupConfirmEmail="1".equals(dbValues.get("SignupConfirmEmail")); smtpServerAddress=dbValues.getOrDefault("Mail_SMTP_ServerAddress", "127.0.0.1"); smtpPort=Utils.parseIntOrDefault(dbValues.get("Mail_SMTP_ServerPort"), 25); diff --git a/src/main/java/smithereen/Mailer.java b/src/main/java/smithereen/Mailer.java index f01befbf..7f7ca5b5 100644 --- a/src/main/java/smithereen/Mailer.java +++ b/src/main/java/smithereen/Mailer.java @@ -92,6 +92,64 @@ public void sendTest(Request req, String to, Account self){ send(to, l.get("email_test_subject"), l.get("email_test"), "test", Map.of("gender", self.user.gender), l.getLocale()); } + public void sendAccountActivation(Request req, Account self){ + Account.ActivationInfo info=self.activationInfo; + if(info==null) + throw new IllegalArgumentException("This account is already activated"); + if(info.emailState!=Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED) + throw new IllegalArgumentException("Unexpected email state "+info.emailState); + if(info.emailConfirmationKey==null) + throw new IllegalArgumentException("No email confirmation key"); + + Lang l=Utils.lang(req); + String link=UriBuilder.local().path("account", "activate").queryParam("key", info.emailConfirmationKey).build().toString(); + String plaintext=l.get("email_confirmation_body_plain", Map.of("name", self.user.firstName, "serverName", Config.domain))+"\n\n"+link; + send(self.email, l.get("email_confirmation_subject", Map.of("domain", Config.domain)), plaintext, "activate_account", Map.of( + "name", self.user.firstName, + "gender", self.user.gender, + "confirmationLink", link + ), l.getLocale()); + } + + public void sendEmailChange(Request req, Account self){ + Account.ActivationInfo info=self.activationInfo; + if(info==null) + throw new IllegalArgumentException("This account is already activated"); + if(info.emailState!=Account.ActivationInfo.EmailConfirmationState.CHANGE_PENDING) + throw new IllegalArgumentException("Unexpected email state "+info.emailState); + if(info.emailConfirmationKey==null) + throw new IllegalArgumentException("No email confirmation key"); + + Lang l=Utils.lang(req); + String link=UriBuilder.local().path("account", "activate").queryParam("key", info.emailConfirmationKey).build().toString(); + String plaintext=l.get("email_change_new_body_plain", Map.of("name", self.user.firstName, "serverName", Config.domain, "newAddress", self.getUnconfirmedEmail(), "oldAddress", self.email))+"\n\n"+link; + send(self.getUnconfirmedEmail(), l.get("email_change_new_subject", Map.of("domain", Config.domain)), plaintext, "update_email_new", Map.of( + "name", self.user.firstName, + "gender", self.user.gender, + "confirmationLink", link, + "oldAddress", self.email, + "newAddress", self.getUnconfirmedEmail() + ), l.getLocale()); + } + + public void sendEmailChangeDoneToPreviousAddress(Request req, Account self){ + Account.ActivationInfo info=self.activationInfo; + if(info==null) + throw new IllegalArgumentException("This account is already activated"); + if(info.emailState!=Account.ActivationInfo.EmailConfirmationState.CHANGE_PENDING) + throw new IllegalArgumentException("Unexpected email state "+info.emailState); + if(info.emailConfirmationKey==null) + throw new IllegalArgumentException("No email confirmation key"); + + Lang l=Utils.lang(req); + String plaintext=l.get("email_change_old_body", Map.of("name", self.user.firstName, "serverName", Config.domain)); + send(self.email, l.get("email_change_old_subject", Map.of("domain", Config.domain)), plaintext, "update_email_old", Map.of( + "name", self.user.firstName, + "gender", self.user.gender, + "address", self.getUnconfirmedEmail() + ), l.getLocale()); + } + private void send(String to, String subject, String plaintext, String templateName, Map templateParams, Locale templateLocale){ try{ MimeMessage msg=new MimeMessage(session); @@ -124,6 +182,10 @@ private void send(String to, String subject, String plaintext, String templateNa } } + public static String generateConfirmationKey(){ + return Utils.randomAlphanumericString(64); + } + private static class SendRunnable implements Runnable{ private final MimeMessage msg; diff --git a/src/main/java/smithereen/SmithereenApplication.java b/src/main/java/smithereen/SmithereenApplication.java index b5a4eb55..90fea720 100644 --- a/src/main/java/smithereen/SmithereenApplication.java +++ b/src/main/java/smithereen/SmithereenApplication.java @@ -179,6 +179,10 @@ public static void main(String[] args){ post("/resetPassword", SessionRoutes::resetPassword); get("/actuallyResetPassword", SessionRoutes::actuallyResetPasswordForm); post("/actuallyResetPassword", SessionRoutes::actuallyResetPassword); + getWithCSRF("/resendConfirmationEmail", SessionRoutes::resendEmailConfirmation); + getLoggedIn("/changeEmailForm", SessionRoutes::changeEmailForm); + postWithCSRF("/changeEmail", SessionRoutes::changeEmail); + getLoggedIn("/activate", SessionRoutes::activateAccount); }); path("/settings", ()->{ @@ -199,6 +203,9 @@ public static void main(String[] args){ postWithCSRF("/blockDomain", SettingsRoutes::blockDomain); getLoggedIn("/confirmUnblockDomain", SettingsRoutes::confirmUnblockDomain); postWithCSRF("/unblockDomain", SettingsRoutes::unblockDomain); + postWithCSRF("/updateEmail", SettingsRoutes::updateEmail); + getWithCSRF("/cancelEmailChange", SettingsRoutes::cancelEmailChange); + getWithCSRF("/resendEmailConfirmation", SettingsRoutes::resendEmailConfirmation); path("/admin", ()->{ getRequiringAccessLevel("", Account.AccessLevel.ADMIN, SettingsAdminRoutes::index); @@ -211,8 +218,10 @@ public static void main(String[] args){ 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); }); }); @@ -494,7 +503,7 @@ public static void main(String[] args){ long t=(long)l; resp.header("X-Generated-In", (System.currentTimeMillis()-t)+""); } - if(req.attribute("isTemplate")!=null){ + if(req.attribute("isTemplate")!=null && !Utils.isAjax(req)){ String cssName=req.attribute("mobile")!=null ? "mobile" : "desktop"; resp.header("Link", "; rel=preload; as=style, ; rel=preload; as=script"); resp.header("Vary", "User-Agent, Accept-Language"); diff --git a/src/main/java/smithereen/Utils.java b/src/main/java/smithereen/Utils.java index 8797fd47..02754228 100644 --- a/src/main/java/smithereen/Utils.java +++ b/src/main/java/smithereen/Utils.java @@ -114,6 +114,17 @@ 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(); @@ -124,13 +135,17 @@ public static boolean requireAccount(Request req, Response resp){ return false; } Account acc=sessionInfo(req).account; - if(acc.banInfo!=null){ + 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; } @@ -179,7 +194,7 @@ public static Object wrapError(Request req, Response resp, String errorKey, Map< Lang l=lang(req); String msg=formatArgs!=null ? l.get(errorKey, formatArgs) : l.get(errorKey); if(isAjax(req)){ - return new WebDeltaResponse(resp).messageBox(l.get("error"), msg, l.get("ok")); + return new WebDeltaResponse(resp).messageBox(l.get("error"), msg, l.get("close")); } return new RenderedTemplateResponse("generic_error", req).with("error", msg).with("back", back(req)).with("title", l.get("error")); } @@ -192,7 +207,7 @@ public static String wrapErrorString(Request req, Response resp, String errorKey Lang l=lang(req); String msg=formatArgs!=null ? l.get(errorKey, formatArgs) : l.get(errorKey); if(isAjax(req)){ - return new WebDeltaResponse(resp).messageBox(l.get("error"), msg, l.get("ok")).json(); + return new WebDeltaResponse(resp).messageBox(l.get("error"), msg, l.get("close")).json(); }else if(isActivityPub(req)){ return msg; } @@ -821,6 +836,28 @@ public static void requireQueryParams(Request req, String... params){ } } + public static String substituteLinks(String str, Map links){ + Element root=Jsoup.parseBodyFragment(str).body(); + for(String id:links.keySet()){ + Element link=root.getElementById(id); + if(link==null) + continue; + link.removeAttr("id"); + //noinspection unchecked + Map attrs=(Map) links.get(id); + for(String attr:attrs.keySet()){ + Object value=attrs.get(attr); + if(attr.equals("_")){ + link.tagName(value.toString()); + }else if(value instanceof Boolean b) + link.attr(attr, b); + else if(value instanceof String s) + link.attr(attr, s); + } + } + return root.html(); + } + public interface MentionCallback{ User resolveMention(String username, String domain); User resolveMention(String uri); diff --git a/src/main/java/smithereen/data/Account.java b/src/main/java/smithereen/data/Account.java index 076ef30f..335a9818 100644 --- a/src/main/java/smithereen/data/Account.java +++ b/src/main/java/smithereen/data/Account.java @@ -4,7 +4,6 @@ import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Timestamp; import java.time.Instant; import smithereen.Utils; @@ -20,6 +19,7 @@ public class Account{ public Instant createdAt; public Instant lastActive; public BanInfo banInfo; + public ActivationInfo activationInfo; public User invitedBy; // used in admin UIs @@ -33,6 +33,8 @@ public String toString(){ ", prefs="+prefs+ ", createdAt="+createdAt+ ", lastActive="+lastActive+ + ", banInfo="+banInfo+ + ", activationInfo="+activationInfo+ ", invitedBy="+invitedBy+ '}'; } @@ -58,9 +60,30 @@ public static Account fromResultSet(ResultSet res) throws SQLException{ acc.prefs=new UserPreferences(); } } + String activation=res.getString("activation_info"); + if(activation!=null) + acc.activationInfo=Utils.gson.fromJson(activation, ActivationInfo.class); return acc; } + public String getUnconfirmedEmail(){ + if(activationInfo==null) + return null; + return switch(activationInfo.emailState){ + case NOT_CONFIRMED -> email; + case CHANGE_PENDING -> activationInfo.newEmail; + }; + } + + public String getCurrentEmailMasked(){ + String[] parts=email.split("@", 2); + if(parts.length!=2) + return email; + String user=parts[0]; + int count=user.length()<5 ? 1 : 2; + return user.substring(0, count)+"*".repeat(user.length()-count)+"@"+parts[1]; + } + public enum AccessLevel{ BANNED, REGULAR, @@ -73,4 +96,15 @@ public static class BanInfo{ public String reason; public Instant when; } + + public static class ActivationInfo{ + public String emailConfirmationKey; + public String newEmail; + public EmailConfirmationState emailState; + + public enum EmailConfirmationState{ + NOT_CONFIRMED, + CHANGE_PENDING + } + } } diff --git a/src/main/java/smithereen/data/WebDeltaResponse.java b/src/main/java/smithereen/data/WebDeltaResponse.java index 75b4aebe..cda4a107 100644 --- a/src/main/java/smithereen/data/WebDeltaResponse.java +++ b/src/main/java/smithereen/data/WebDeltaResponse.java @@ -102,6 +102,11 @@ public WebDeltaResponse runScript(@NotNull String script){ return this; } + public WebDeltaResponse keepBox(){ + commands.add(new KeepBoxCommand()); + return this; + } + public String json(){ return Utils.gson.toJson(commands); } @@ -308,4 +313,11 @@ public RunScriptCommand(String script){ this.script=script; } } + + public static class KeepBoxCommand extends Command{ + + public KeepBoxCommand(){ + super("kb"); + } + } } diff --git a/src/main/java/smithereen/routes/SessionRoutes.java b/src/main/java/smithereen/routes/SessionRoutes.java index 8cf7d954..2cc6fc18 100644 --- a/src/main/java/smithereen/routes/SessionRoutes.java +++ b/src/main/java/smithereen/routes/SessionRoutes.java @@ -1,8 +1,12 @@ package smithereen.routes; +import com.mitchellbosecke.pebble.extension.escaper.SafeString; + import java.sql.SQLException; import java.util.Base64; import java.util.Locale; +import java.util.Map; +import java.util.Objects; import smithereen.Config; import smithereen.Mailer; @@ -11,7 +15,11 @@ import smithereen.data.EmailCode; import smithereen.data.SessionInfo; import smithereen.data.User; +import smithereen.data.WebDeltaResponse; import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.InternalServerErrorException; +import smithereen.exceptions.UserErrorException; +import smithereen.lang.Lang; import smithereen.storage.DatabaseUtils; import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; @@ -145,6 +153,8 @@ public static Object doRegister(Request req, Response resp) throws SQLException{ return regError(req, "err_invalid_email"); if(StringUtils.isEmpty(first) || first.length()<2) return regError(req, "err_name_too_short"); + if(SessionStorage.getAccountByEmail(email)!=null) + return regError(req, "err_reg_email_taken"); if(Config.signupMode!=Config.SignupMode.OPEN){ if(StringUtils.isEmpty(invite)) invite=req.queryParams("_invite"); @@ -160,7 +170,15 @@ public static Object doRegister(Request req, Response resp) throws SQLException{ else res=SessionStorage.registerNewAccount(username, password, email, first, last, gender, invite); if(res==SessionStorage.SignupResult.SUCCESS){ - Account acc=SessionStorage.getAccountForUsernameAndPassword(username, password); + Account acc=Objects.requireNonNull(SessionStorage.getAccountForUsernameAndPassword(username, password)); + if(Config.signupConfirmEmail){ + Account.ActivationInfo info=new Account.ActivationInfo(); + info.emailState=Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED; + info.emailConfirmationKey=Mailer.generateConfirmationKey(); + acc.activationInfo=info; + SessionStorage.updateActivationInfo(acc.id, info); + Mailer.getInstance().sendAccountActivation(req, acc); + } setupSessionWithAccount(req, resp, acc); resp.redirect("/feed"); }else if(res==SessionStorage.SignupResult.USERNAME_TAKEN){ @@ -282,4 +300,78 @@ public static Object actuallyResetPassword(Request req, Response resp) throws SQ return ""; } + + public static Object resendEmailConfirmation(Request req, Response resp, Account self){ + if(self.getUnconfirmedEmail()==null) + throw new BadRequestException(); + FloodControl.EMAIL_CONFIRM_RESEND.incrementOrThrow(self.getUnconfirmedEmail()); + Mailer.getInstance().sendAccountActivation(req, self); + Lang l=lang(req); + String msg=l.get("email_confirmation_resent", Map.of("address", escapeHTML(self.getUnconfirmedEmail()))).replace("\n", "
"); + msg=substituteLinks(msg, Map.of("change", Map.of("href", "/account/changeEmailForm", "data-ajax-box", ""))); + if(isAjax(req)) + return new WebDeltaResponse(resp).messageBox(l.get("account_activation"), msg, l.get("close")); + return new RenderedTemplateResponse("generic_message", req).with("message", new SafeString(msg)).pageTitle(l.get("account_activation")); + } + + public static Object changeEmailForm(Request req, Response resp, Account self){ + if(self.getUnconfirmedEmail()==null) + throw new BadRequestException(); + RenderedTemplateResponse model=new RenderedTemplateResponse("change_email_form", req); + model.with("email", self.getUnconfirmedEmail()); + return wrapForm(req, resp, "change_email_form", "/account/changeEmail", lang(req).get("change_email_title"), "save", model); + } + + public static Object changeEmail(Request req, Response resp, Account self){ + if(self.activationInfo==null || self.activationInfo.emailState!=Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED) + throw new BadRequestException(); + String email=req.queryParams("email"); + if(!isValidEmail(email)) + throw new BadRequestException(); + if(email.equalsIgnoreCase(self.email)) + return ""; + try{ + if(SessionStorage.getAccountByEmail(email)!=null){ + if(isAjax(req)){ + return new WebDeltaResponse(resp).show("formMessage_changeEmail").setContent("formMessage_changeEmail", lang(req).get("err_reg_email_taken")).keepBox(); + } + return ""; + } + + self.activationInfo.emailConfirmationKey=Mailer.generateConfirmationKey(); + SessionStorage.updateActivationInfo(self.id, self.activationInfo); + SessionStorage.updateEmail(self.id, email); + self.email=email; + + FloodControl.EMAIL_CONFIRM_RESEND.incrementOrThrow(self.getUnconfirmedEmail()); + Mailer.getInstance().sendAccountActivation(req, self); + + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect("/feed"); + return ""; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + + public static Object activateAccount(Request req, Response resp, Account self){ + if(self.activationInfo==null) + throw new UserErrorException("err_email_already_activated"); + if(!self.activationInfo.emailConfirmationKey.equals(req.queryParams("key"))) + throw new UserErrorException("err_email_link_invalid"); + Account.ActivationInfo.EmailConfirmationState state=self.activationInfo.emailState; + try{ + if(self.activationInfo.emailState==Account.ActivationInfo.EmailConfirmationState.CHANGE_PENDING){ + Mailer.getInstance().sendEmailChangeDoneToPreviousAddress(req, self); + SessionStorage.updateEmail(self.id, self.activationInfo.newEmail); + self.email=self.activationInfo.newEmail; + } + SessionStorage.updateActivationInfo(self.id, null); + self.activationInfo=null; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + return new RenderedTemplateResponse("email_confirm_success", req).with("activated", state==Account.ActivationInfo.EmailConfirmationState.NOT_CONFIRMED); + } } diff --git a/src/main/java/smithereen/routes/SettingsAdminRoutes.java b/src/main/java/smithereen/routes/SettingsAdminRoutes.java index de153b02..b4cf7152 100644 --- a/src/main/java/smithereen/routes/SettingsAdminRoutes.java +++ b/src/main/java/smithereen/routes/SettingsAdminRoutes.java @@ -13,8 +13,10 @@ import smithereen.data.PaginatedList; import smithereen.data.User; import smithereen.data.WebDeltaResponse; +import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.ObjectNotFoundException; import smithereen.lang.Lang; +import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; import smithereen.templates.RenderedTemplateResponse; import spark.Request; @@ -33,7 +35,8 @@ public static Object index(Request req, Response resp, Account self){ .with("serverShortDescription", Config.serverShortDescription) .with("serverPolicy", Config.serverPolicy) .with("serverAdminEmail", Config.serverAdminEmail) - .with("signupMode", Config.signupMode); + .with("signupMode", Config.signupMode) + .with("signupConfirmEmail", Config.signupConfirmEmail); String msg=req.session().attribute("admin.serverInfoMessage"); if(StringUtils.isNotEmpty(msg)){ req.session().removeAttribute("admin.serverInfoMessage"); @@ -48,18 +51,21 @@ public static Object updateServerInfo(Request req, Response resp, Account self) String shortDescr=req.queryParams("server_short_description"); String policy=req.queryParams("server_policy"); String email=req.queryParams("server_admin_email"); + boolean confirmEmail="on".equals(req.queryParams("signup_confirm_email")); Config.serverDisplayName=name; Config.serverDescription=descr; Config.serverShortDescription=shortDescr; Config.serverPolicy=policy; Config.serverAdminEmail=email; + Config.signupConfirmEmail=confirmEmail; Config.updateInDatabase(Map.of( "ServerDisplayName", name, "ServerDescription", descr, "ServerShortDescription", shortDescr, "ServerPolicy", policy, - "ServerAdminEmail", email + "ServerAdminEmail", email, + "SignupConfirmEmail", confirmEmail ? "1" : "0" )); try{ Config.SignupMode signupMode=Config.SignupMode.valueOf(req.queryParams("signup_mode")); @@ -245,4 +251,32 @@ public static Object sendTestEmail(Request req, Response resp, Account self) thr resp.redirect("/settings/admin/other"); return ""; } + + public static Object confirmActivateAccount(Request req, Response resp, Account self) 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_activate_X_confirm", Map.of("name", user.getFirstLastAndGender()))).with("formAction", "/settings/admin/users/activate?accountID="+accountID+"&_redir="+URLEncoder.encode(back)).with("back", back); + } + + public static Object activateAccount(Request req, Response resp, Account self){ + try{ + int accountID=parseIntOrDefault(req.queryParams("accountID"), 0); + Account target=UserStorage.getAccount(accountID); + if(target==null) + throw new ObjectNotFoundException("err_user_not_found"); + SessionStorage.updateActivationInfo(accountID, null); + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } } diff --git a/src/main/java/smithereen/routes/SettingsRoutes.java b/src/main/java/smithereen/routes/SettingsRoutes.java index 0ee07724..1f7cce8d 100644 --- a/src/main/java/smithereen/routes/SettingsRoutes.java +++ b/src/main/java/smithereen/routes/SettingsRoutes.java @@ -26,6 +26,7 @@ import smithereen.Config; import static smithereen.Utils.*; +import smithereen.Mailer; import smithereen.Utils; import smithereen.activitypub.ActivityPubWorker; import smithereen.activitypub.objects.Image; @@ -37,6 +38,7 @@ import smithereen.data.User; import smithereen.data.WebDeltaResponse; import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.InternalServerErrorException; import smithereen.exceptions.UserActionNotAllowedException; import smithereen.lang.Lang; import smithereen.libvips.VipsImage; @@ -45,6 +47,7 @@ import smithereen.storage.SessionStorage; import smithereen.storage.UserStorage; import smithereen.templates.RenderedTemplateResponse; +import smithereen.util.FloodControl; import spark.Request; import spark.Response; import spark.Session; @@ -71,6 +74,12 @@ public static Object settings(Request req, Response resp, Account self) throws S model.with("profilePicMessage", s.attribute("settings.profilePicMessage")); s.removeAttribute("settings.profilePicMessage"); } + if(s.attribute("settings.emailMessage")!=null){ + model.with("emailMessage", s.attribute("settings.emailMessage")); + s.removeAttribute("settings.emailMessage"); + } + model.with("activationInfo", self.activationInfo); + model.with("currentEmailMasked", self.getCurrentEmailMasked()); model.with("title", lang(req).get("settings")); return model; } @@ -410,4 +419,70 @@ public static Object unblockDomain(Request req, Response resp, Account self) thr resp.redirect(back(req)); return ""; } + + public static Object updateEmail(Request req, Response resp, Account self){ + String email=req.queryParams("email"); + Lang l=lang(req); + String message; + if(!isValidEmail(email)){ + message=l.get("err_invalid_email"); + }else{ + try{ + if(email.equals(self.email) || email.equals(self.getUnconfirmedEmail())){ + message=null; + }else if(Config.signupConfirmEmail){ + self.activationInfo=new Account.ActivationInfo(); + self.activationInfo.emailConfirmationKey=Mailer.generateConfirmationKey(); + self.activationInfo.emailState=Account.ActivationInfo.EmailConfirmationState.CHANGE_PENDING; + self.activationInfo.newEmail=email; + SessionStorage.updateActivationInfo(self.id, self.activationInfo); + FloodControl.EMAIL_CONFIRM_RESEND.incrementOrThrow(self.getUnconfirmedEmail()); + Mailer.getInstance().sendEmailChange(req, self); + message=l.get("change_email_sent"); + }else{ + SessionStorage.updateEmail(self.id, email); + message=l.get("email_confirmed_changed"); + } + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + } + if(message==null) + return ""; + if(isAjax(req)){ + return new WebDeltaResponse(resp).show("formMessage_changeEmail").setContent("formMessage_changeEmail", message); + } + Session s=req.session(); + s.attribute("settings.emailMessage", message); + resp.redirect(back(req)); + return ""; + } + + public static Object cancelEmailChange(Request req, Response resp, Account self){ + if(self.activationInfo==null || self.activationInfo.emailState!=Account.ActivationInfo.EmailConfirmationState.CHANGE_PENDING) + throw new BadRequestException(); + try{ + SessionStorage.updateActivationInfo(self.id, null); + self.activationInfo=null; + }catch(SQLException x){ + throw new InternalServerErrorException(x); + } + if(isAjax(req)) + return new WebDeltaResponse(resp).refresh(); + resp.redirect(back(req)); + return ""; + } + + public static Object resendEmailConfirmation(Request req, Response resp, Account self){ + if(self.activationInfo==null || self.activationInfo.emailState!=Account.ActivationInfo.EmailConfirmationState.CHANGE_PENDING) + throw new BadRequestException(); + FloodControl.EMAIL_CONFIRM_RESEND.incrementOrThrow(self.getUnconfirmedEmail()); + Mailer.getInstance().sendEmailChange(req, self); + if(isAjax(req)){ + Lang l=lang(req); + return new WebDeltaResponse(resp).messageBox(l.get("change_email_title"), l.get("email_confirmation_resent_short", Map.of("address", self.getUnconfirmedEmail())), l.get("close")); + } + resp.redirect(back(req)); + return ""; + } } diff --git a/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java b/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java index b06f916b..f1cd2d9a 100644 --- a/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java +++ b/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java @@ -13,7 +13,7 @@ import smithereen.activitypub.objects.Actor; public class DatabaseSchemaUpdater{ - public static final int SCHEMA_VERSION=23; + public static final int SCHEMA_VERSION=24; private static final Logger LOG=LoggerFactory.getLogger(DatabaseSchemaUpdater.class); public static void maybeUpdate() throws SQLException{ @@ -352,6 +352,7 @@ PRIMARY KEY (`id`), conn.createStatement().execute("ALTER TABLE `polls` ADD `last_vote_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP()"); conn.createStatement().execute("CREATE INDEX `poll_id` ON `wall_posts` (`poll_id`)"); } + case 24 -> conn.createStatement().execute("ALTER TABLE `accounts` ADD `activation_info` json DEFAULT NULL"); } } } diff --git a/src/main/java/smithereen/storage/SessionStorage.java b/src/main/java/smithereen/storage/SessionStorage.java index 8ac9bba6..e8bda51d 100644 --- a/src/main/java/smithereen/storage/SessionStorage.java +++ b/src/main/java/smithereen/storage/SessionStorage.java @@ -250,6 +250,14 @@ public static SignupResult registerNewAccount(@NotNull String username, @NotNull return SignupResult.SUCCESS; } + public static void updateActivationInfo(int accountID, Account.ActivationInfo info) throws SQLException{ + new SQLQueryBuilder() + .update("accounts") + .value("activation_info", info==null ? null : Utils.gson.toJson(info)) + .where("id=?", accountID) + .executeNoResult(); + } + public static boolean updatePassword(int accountID, String oldPassword, String newPassword) throws SQLException{ try{ MessageDigest md=MessageDigest.getInstance("SHA-256"); @@ -265,6 +273,14 @@ public static boolean updatePassword(int accountID, String oldPassword, String n return false; } + public static void updateEmail(int accountID, String email) throws SQLException{ + new SQLQueryBuilder() + .update("accounts") + .value("email", email) + .where("id=?", accountID) + .executeNoResult(); + } + public static void updatePreferences(int accountID, UserPreferences prefs) throws SQLException{ Connection conn=DatabaseConnectionManager.getConnection(); PreparedStatement stmt=conn.prepareStatement("UPDATE `accounts` SET `preferences`=? WHERE `id`=?"); diff --git a/src/main/java/smithereen/templates/LangFunction.java b/src/main/java/smithereen/templates/LangFunction.java index 49f58b2a..f4ffac82 100644 --- a/src/main/java/smithereen/templates/LangFunction.java +++ b/src/main/java/smithereen/templates/LangFunction.java @@ -5,16 +5,11 @@ import com.mitchellbosecke.pebble.template.EvaluationContext; import com.mitchellbosecke.pebble.template.PebbleTemplate; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; -import org.unbescape.html.HtmlEscape; - import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; +import smithereen.Utils; import smithereen.lang.Lang; public class LangFunction implements Function{ @@ -37,7 +32,7 @@ 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(substituteLinks(formatted, links)); + return new SafeString(Utils.substituteLinks(formatted, links)); }else{ return new SafeString(formatted); } @@ -48,26 +43,4 @@ public Object execute(Map args, PebbleTemplate self, EvaluationC public List getArgumentNames(){ return List.of("key", "vars", "links"); } - - private String substituteLinks(String str, Map links){ - Element root=Jsoup.parseBodyFragment(str).body(); - for(String id:links.keySet()){ - Element link=root.getElementById(id); - if(link==null) - continue; - link.removeAttr("id"); - //noinspection unchecked - Map attrs=(Map) links.get(id); - for(String attr:attrs.keySet()){ - Object value=attrs.get(attr); - if(attr.equals("_")){ - link.tagName(value.toString()); - }else if(value instanceof Boolean b) - link.attr(attr, b); - else if(value instanceof String s) - link.attr(attr, s); - } - } - return root.html(); - } } diff --git a/src/main/java/smithereen/templates/Templates.java b/src/main/java/smithereen/templates/Templates.java index 36191caa..7e2deef0 100644 --- a/src/main/java/smithereen/templates/Templates.java +++ b/src/main/java/smithereen/templates/Templates.java @@ -99,7 +99,7 @@ public static void addGlobalParamsToTemplate(Request req, RenderedTemplateRespon jsLang.add("\""+key+"\":"+lang.getAsJS(key)); } } - for(String key: List.of("error", "ok", "network_error", "close")){ + for(String key: List.of("error", "ok", "network_error", "close", "cancel")){ jsLang.add("\""+key+"\":"+lang.getAsJS(key)); } if(req.attribute("mobile")!=null){ diff --git a/src/main/java/smithereen/util/FloodControl.java b/src/main/java/smithereen/util/FloodControl.java index 70a345f8..bcaa46be 100644 --- a/src/main/java/smithereen/util/FloodControl.java +++ b/src/main/java/smithereen/util/FloodControl.java @@ -15,6 +15,7 @@ */ public class FloodControl{ public static final FloodControl PASSWORD_RESET=FloodControl.ofObjectKey(1, 10, TimeUnit.MINUTES, acc -> "account"+acc.id); + public static final FloodControl EMAIL_CONFIRM_RESEND=FloodControl.ofStringKey(1, 10, TimeUnit.MINUTES); private long timeout; private int count; diff --git a/src/main/resources/langs/en.json b/src/main/resources/langs/en.json index b732c5c9..eee30f1b 100644 --- a/src/main/resources/langs/en.json +++ b/src/main/resources/langs/en.json @@ -253,9 +253,9 @@ "confirm_unblock_domain_X": "Are you sure you want to unblock domain {domain}?", "manage_group": "Manage group", "forgot_password": "Forgot your password?", - "email_password_reset_subject": "{serverName} password reset", + "email_password_reset_subject": "{domain} password reset", "email_password_reset_html_before": "{name}, click the button below to reset the password for your account at {serverName}. This link is valid for 24 hours.", - "email_password_reset_plain_before": "{name}, go to the address below to reset the password for your account at {serverName}. This link is valid for 24 hours.", + "email_password_reset_plain_before": "{name}, follow the link below to reset the password for your account at {serverName}. This link is valid for 24 hours.", "email_password_reset_after": "If you haven't requested a password request, ignore this email. Your account is safe.", "reset_password": "Reset my password", "reset_password_title": "Reset your password", @@ -511,5 +511,36 @@ "sync_profile": "Sync profile", "sync_started": "Synchronization started and is being performed in the background.", "friend_req_accepted": "Friend request accepted", - "friend_req_declined": "Friend request declined" + "friend_req_declined": "Friend request declined", + "admin_require_email_confirm": "Require confirmed email for new accounts", + "account_inactive_confirm_email": "You need to confirm your email before you can use your account. Please click the link you received on {address}.", + "resend_confirm_email": "Resend confirmation", + "change_email_address": "Change email address", + "change_email_title": "Change email", + "change_email": "Change email", + "email_confirmation_subject": "Welcome to {domain}!", + "email_confirmation_body_html": "{name}, thanks for signing up for an account at {serverName}. Please click the button below to activate your account.", + "email_confirmation_body_plain": "{name}, thanks for signing up for an account at {serverName}. Please follow the link below to activate your account.", + "email_confirmation_body_button": "Activate my account", + "account_activation": "Account activation", + "email_confirmation_resent": "A new email was sent to {address}.\n\nIf this is not the right address, you can change it.", + "new_email_address": "New email", + "err_reg_email_taken": "There's already an account with this email.", + "email_confirmed_activated": "You've activated your account. Welcome!", + "email_confirmed_changed": "You've changed your email.", + "proceed_fill_profile": "Set up your profile", + "err_email_link_invalid": "This link is invalid.", + "err_email_already_activated": "Your account is already active.", + "admin_activate_account": "Activate", + "admin_activate_X_confirm": "Are you sure you want to activate {name}'s account?", + "pending_email_change": "You have previously requested to change your email to {address} but haven't opened the link sent to the new address. If you still haven't received the confirmation link, you can resend it or cancel the email change request.", + "email_change_old_subject": "Your email address on {domain} was updated", + "email_change_old_body": "{name}, the email address on your {serverName} account has been successfully changed to {address}. If it was you, please ignore this email. Otherwise, please log in and change your password immediately to secure your account.", + "email_change_new_subject": "Confirm your new email for {domain}", + "email_change_new_body_html": "{name}, please click the button below to finish changing the email address on your account from {oldAddress} to {newAddress}.", + "email_change_new_body_plain": "{name}, please follow the link below to finish changing the email address on your account from {oldAddress} to {newAddress}.", + "email_change_new_body_button": "Update my email", + "change_email_sent": "Please follow the link sent to your new email address to finish changing the email on your account.", + "current_email": "Current email", + "email_confirmation_resent_short": "A new email was sent to {address}." } \ No newline at end of file diff --git a/src/main/resources/langs/ru.json b/src/main/resources/langs/ru.json index adbb4c68..adcf0e85 100644 --- a/src/main/resources/langs/ru.json +++ b/src/main/resources/langs/ru.json @@ -507,5 +507,36 @@ "sync_profile": "Обновить профиль", "sync_started": "Процесс синхронизации запущен и выполняется в фоновом режиме.", "friend_req_accepted": "Заявка принята", - "friend_req_declined": "Заявка отклонена" + "friend_req_declined": "Заявка отклонена", + "admin_require_email_confirm": "Требовать подтверждение почты для новых аккаунтов", + "account_inactive_confirm_email": "Необходимо подтвердить email перед тем, как Вы сможете пользоваться своей учётной записью. Пожалуйста, перейдите по ссылке, которую Вы получили на {address}.", + "resend_confirm_email": "Отправить ещё раз", + "change_email_address": "Изменить адрес", + "change_email_title": "Изменение email", + "change_email": "Сменить email", + "email_confirmation_subject": "Добро пожаловать на {domain}!", + "email_confirmation_body_html": "{name}, благодарим за регистрацию на {serverName}. Пожалуйста, нажмите на кнопку ниже, чтобы активировать свою учётную запись.", + "email_confirmation_body_plain": "{name}, благодарим за регистрацию на {serverName}. Пожалуйста, перейдите по ссылке ниже, чтобы активировать свою учётную запись.", + "email_confirmation_body_button": "Активировать учётную запись", + "account_activation": "Активация учётной записи", + "email_confirmation_resent": "На {address} отправлено ещё одно письмо.\n\nЕсли это неправильный адрес, Вы можете его изменить.", + "new_email_address": "Новый email", + "err_reg_email_taken": "Уже есть пользователь с таким email.", + "email_confirmed_activated": "Вы активировали свою учётную запись. Добро пожаловать!", + "email_confirmed_changed": "Вы сменили email.", + "proceed_fill_profile": "Заполнить свою страницу", + "err_email_link_invalid": "Эта ссылка недействительна.", + "err_email_already_activated": "Ваша учётная запись уже активирована.", + "admin_activate_account": "Активировать", + "admin_activate_X_confirm": "Вы действительно хотите активировать учётную запись {name, inflect, genitive}?", + "pending_email_change": "Вы ранее запросили смену email на {address}, но не перешли по ссылке, отправленной на новый адрес. Если Вы не так и получили письмо со ссылкой, Вы можете отправить его ещё раз или отменить запрос на смену почты.", + "email_change_old_subject": "Ваш email-адрес на {domain} был изменён", + "email_change_old_body": "{name}, адрес почты в Вашей учётной записи на {serverName} был успешно изменён на {address}. Если это были Вы, пожалуйста, проигнорируйте это письмо. В противном случае, как можно скорее войдите в свою учётную запись и смените пароль, чтобы сохранить её в безопасности.", + "email_change_new_subject": "Подтвердите смену email на {domain}", + "email_change_new_body_html": "{name}, пожалуйста, нажмите на кнопку ниже для завершения смены почтового адреса в Вашей учётной записи с {oldAddress} на {newAddress}.", + "email_change_new_body_plain": "{name}, пожалуйста, перейдите по ссылке ниже для завершения смены почтового адреса в Вашей учётной записи с {oldAddress} на {newAddress}.", + "email_change_new_body_button": "Обновить адрес", + "change_email_sent": "Пожалуйста, перейдите по ссылке, отправленной на Ваш новый адрес, чтобы завершить процесс смены email.", + "current_email": "Текущий email", + "email_confirmation_resent_short": "На {address} отправлено ещё одно письмо." } \ No newline at end of file diff --git a/src/main/resources/templates/common/admin_server_info.twig b/src/main/resources/templates/common/admin_server_info.twig index 50574568..523d25e3 100644 --- a/src/main/resources/templates/common/admin_server_info.twig +++ b/src/main/resources/templates/common/admin_server_info.twig @@ -16,6 +16,7 @@ {'value': 'INVITE_ONLY', 'label': L('admin_signup_mode_invite'), 'selected': signupMode=='INVITE_ONLY'}, {'value': 'CLOSED', 'label': L('admin_signup_mode_closed'), 'selected': signupMode=='CLOSED'} ], {'explanation': L('admin_signup_mode_explain')})}} + {{ form.checkBox('signup_confirm_email', null, L('admin_require_email_confirm'), signupConfirmEmail) }} {{form.footer(L('save'))}} {{form.end()}} diff --git a/src/main/resources/templates/common/admin_users.twig b/src/main/resources/templates/common/admin_users.twig index 5f26def0..43e22ab0 100644 --- a/src/main/resources/templates/common/admin_users.twig +++ b/src/main/resources/templates/common/admin_users.twig @@ -27,6 +27,9 @@ | {{ L('admin_unban') }} {% endif %} {% endif %} + {% if acc.activationInfo is not null %} + | {{ L('admin_activate_account') }} + {% endif %} {% endfor %} diff --git a/src/main/resources/templates/common/change_email_form.twig b/src/main/resources/templates/common/change_email_form.twig new file mode 100644 index 00000000..438cce51 --- /dev/null +++ b/src/main/resources/templates/common/change_email_form.twig @@ -0,0 +1,4 @@ +{% import "forms" as form %} +{{ form.start("changeEmail") }} + {{ form.textInput('email', L('new_email_address'), email, {'type': 'email', 'required': true}) }} +{{ form.end() }} diff --git a/src/main/resources/templates/common/email_confirm_required.twig b/src/main/resources/templates/common/email_confirm_required.twig new file mode 100644 index 00000000..c96cbfe8 --- /dev/null +++ b/src/main/resources/templates/common/email_confirm_required.twig @@ -0,0 +1,15 @@ +{% extends "page" %} +{% block content %} +
+
+

{{ L('account_activation') }}

+
{{ L('account_inactive_confirm_email', {'address': email}) }}
+ +
+
+{% endblock %} + diff --git a/src/main/resources/templates/common/email_confirm_success.twig b/src/main/resources/templates/common/email_confirm_success.twig new file mode 100644 index 00000000..e028515b --- /dev/null +++ b/src/main/resources/templates/common/email_confirm_success.twig @@ -0,0 +1,18 @@ +{% extends "page" %} +{% block content %} +
+
+

{{ L(activated ? 'account_activation' : 'change_email_title') }}

+ {% if activated %} +
{{ L('email_confirmed_activated') }}
+ + {% else %} +
{{ L('email_confirmed_changed') }}
+ {% endif %} +
+
+{% endblock %} + diff --git a/src/main/resources/templates/common/generic_message.twig b/src/main/resources/templates/common/generic_message.twig index b7a37404..ecd9a459 100644 --- a/src/main/resources/templates/common/generic_message.twig +++ b/src/main/resources/templates/common/generic_message.twig @@ -1,7 +1,7 @@ {% extends "page" %} {% block content %}
-
{{ message | nl2br }}
+
{{ message }}
{% endblock %} diff --git a/src/main/resources/templates/common/settings.twig b/src/main/resources/templates/common/settings.twig index 7162e77d..122e4dbe 100644 --- a/src/main/resources/templates/common/settings.twig +++ b/src/main/resources/templates/common/settings.twig @@ -12,6 +12,18 @@ {{ form.footer(L('change_password')) }} {{ form.end() }} + +
+

{{ L('change_email') }}

+ {% if activationInfo is not null %} +
{{ L('pending_email_change', {'address': activationInfo.newEmail}, {'resend': {'href': "/settings/resendEmailConfirmation?csrf=#{csrf}", 'data-ajax': ''}, 'cancel': {'href': "/settings/cancelEmailChange?csrf=#{csrf}", 'data-ajax': ''} }) }}
+ {% endif %} + {{ form.start('changeEmail', emailMessage) }} + {{ form.labeledText(L('current_email'), currentEmailMasked) }} + {{ form.textInput('email', L('new_email_address'), '', {'type': 'email'}) }} + {{ form.footer(L('save')) }} + {{ form.end() }} +
{%if signupMode=='INVITE_ONLY' or (userPermissions.serverAccessLevel=='ADMIN' and signupMode!='OPEN')%}

{{L('invitations')}}

diff --git a/src/main/resources/templates/desktop/forms.twig b/src/main/resources/templates/desktop/forms.twig index f2361a99..d7f62410 100644 --- a/src/main/resources/templates/desktop/forms.twig +++ b/src/main/resources/templates/desktop/forms.twig @@ -124,6 +124,37 @@ autoSizeTextArea(ge("{{ name }}")); {%endmacro%} +{%macro checkBox(name, label, title, value, options)%} + + + {% if label is not empty %} + {{label}}: + {% endif %} + + +
+ +
+ {% if options.explanation is not empty %} + {{ options.explanation }} + {% endif %} + + +{%endmacro%} + +{%macro labeledText(label, text)%} + + + {% if label is not empty %} + {{label}}: + {% endif %} + + + {{ text }} + + +{%endmacro%} + {%macro footer(submitTitle)%} diff --git a/src/main/resources/templates/email/activate_account.twig b/src/main/resources/templates/email/activate_account.twig new file mode 100644 index 00000000..0c42802b --- /dev/null +++ b/src/main/resources/templates/email/activate_account.twig @@ -0,0 +1,8 @@ +{% extends "page" %} +{% block content %} + {{ L('email_confirmation_body_html', {'name': name, 'serverName': domain}) }} +
+ {{ L('email_confirmation_body_button') }} +
+{% endblock %} + diff --git a/src/main/resources/templates/email/update_email_new.twig b/src/main/resources/templates/email/update_email_new.twig new file mode 100644 index 00000000..e8b35685 --- /dev/null +++ b/src/main/resources/templates/email/update_email_new.twig @@ -0,0 +1,8 @@ +{% extends "page" %} +{% block content %} + {{ L('email_change_new_body_html', {'name': name, 'serverName': domain, 'oldAddress': oldAddress, 'newAddress': newAddress}) }} +
+ {{ L('email_change_new_body_button') }} +
+{% endblock %} + diff --git a/src/main/resources/templates/email/update_email_old.twig b/src/main/resources/templates/email/update_email_old.twig new file mode 100644 index 00000000..91784944 --- /dev/null +++ b/src/main/resources/templates/email/update_email_old.twig @@ -0,0 +1,5 @@ +{% extends "page" %} +{% block content %} + {{ L('email_change_old_body', {'name': name, 'serverName': domain, 'address': address}) }} +{% endblock %} + diff --git a/src/main/resources/templates/mobile/forms.twig b/src/main/resources/templates/mobile/forms.twig index 51efd829..74eae4f8 100644 --- a/src/main/resources/templates/mobile/forms.twig +++ b/src/main/resources/templates/mobile/forms.twig @@ -112,6 +112,34 @@ autoSizeTextArea(ge("{{ name }}")); {%endmacro%} +{%macro checkBox(name, label, title, value, options)%} +
+ {% if label is not empty %} +
+ {{label}}: +
+ {% endif %} +
+ + +
+ {% if options.explanation is not empty %} + {{ options.explanation }} + {% endif %} +
+{%endmacro%} + +{%macro labeledText(label, text)%} +
+ {% if label is not empty %} +
+ {{label}}: +
+ {% endif %} + {{ text }} +
+{%endmacro%} + {%macro footer(submitTitle)%}