Skip to content

Commit 79a6ea4

Browse files
committed
Add admin-configurable custom CSS
1 parent db78073 commit 79a6ea4

File tree

13 files changed

+311
-7
lines changed

13 files changed

+311
-7
lines changed

src/main/java/smithereen/Config.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@
3535
import java.util.function.Function;
3636
import java.util.stream.Collectors;
3737

38+
import smithereen.exceptions.InternalServerErrorException;
3839
import smithereen.model.ObfuscatedObjectIDType;
3940
import smithereen.model.admin.UserRole;
4041
import smithereen.storage.sql.SQLQueryBuilder;
4142
import smithereen.storage.sql.DatabaseConnection;
4243
import smithereen.storage.sql.DatabaseConnectionManager;
44+
import smithereen.util.CryptoUtils;
4345
import smithereen.util.PublicSuffixList;
4446
import smithereen.util.TopLevelDomainList;
4547
import spark.utils.StringUtils;
@@ -88,6 +90,8 @@ public class Config{
8890
public static SignupMode signupMode=SignupMode.CLOSED;
8991
public static boolean signupConfirmEmail;
9092
public static boolean signupFormUseCaptcha;
93+
public static String commonCSS, desktopCSS, mobileCSS;
94+
public static String combinedDesktopCSS, combinedMobileCSS, desktopCSSCacheHash, mobileCSSCacheHash;
9195

9296
public static String mailFrom;
9397
public static String smtpServerAddress;
@@ -271,6 +275,11 @@ public static void loadFromDatabase() throws SQLException{
271275
if(PublicSuffixList.lastUpdatedTime>0){
272276
PublicSuffixList.update(Arrays.asList(dbValues.get("PSList_Data").split("\n")));
273277
}
278+
279+
commonCSS=dbValues.get("CommonCSS");
280+
desktopCSS=dbValues.get("DesktopCSS");
281+
mobileCSS=dbValues.get("MobileCSS");
282+
applyCSS(commonCSS, desktopCSS, mobileCSS);
274283
}
275284
}
276285

@@ -327,6 +336,49 @@ private static String requireProperty(Properties props, String name){
327336
return value;
328337
}
329338

339+
private static void applyCSS(String common, String desktop, String mobile){
340+
if(StringUtils.isNotEmpty(common)){
341+
desktop=StringUtils.isEmpty(desktop) ? common : common+"\n"+desktop;
342+
mobile=StringUtils.isEmpty(mobile) ? common : common+"\n"+mobile;
343+
}
344+
if(desktop!=null)
345+
desktop=desktop.trim();
346+
if(mobile!=null)
347+
mobile=mobile.trim();
348+
349+
if(StringUtils.isEmpty(desktop)){
350+
combinedDesktopCSS=null;
351+
desktopCSSCacheHash=null;
352+
}else{
353+
combinedDesktopCSS=desktop;
354+
desktopCSSCacheHash=Utils.byteArrayToHexString(CryptoUtils.sha1(desktop.getBytes(StandardCharsets.UTF_8)));
355+
}
356+
357+
if(StringUtils.isEmpty(mobile)){
358+
combinedMobileCSS=null;
359+
mobileCSSCacheHash=null;
360+
}else{
361+
combinedMobileCSS=mobile;
362+
mobileCSSCacheHash=Utils.byteArrayToHexString(CryptoUtils.sha1(mobile.getBytes(StandardCharsets.UTF_8)));
363+
}
364+
}
365+
366+
public static void updateCSS(String common, String desktop, String mobile){
367+
common=common.trim();
368+
desktop=desktop.trim();
369+
mobile=mobile.trim();
370+
371+
commonCSS=common;
372+
desktopCSS=desktop;
373+
mobileCSS=mobile;
374+
try{
375+
updateInDatabase(Map.of("CommonCSS", common, "DesktopCSS", desktop, "MobileCSS", mobile));
376+
}catch(SQLException x){
377+
throw new InternalServerErrorException(x);
378+
}
379+
applyCSS(common, desktop, mobile);
380+
}
381+
330382
public enum SignupMode{
331383
OPEN,
332384
CLOSED,

src/main/java/smithereen/SmithereenApplication.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,10 @@ public static void main(String[] args){
384384
postRequiringPermissionWithCSRF("/updateServerInfo", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::updateServerInfo);
385385
getRequiringPermission("/createReportForm", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::createReportForm);
386386
postRequiringPermissionWithCSRF("/createReport", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::createReport);
387+
getRequiringPermission("/css", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::customCSS);
388+
postRequiringPermissionWithCSRF("/saveCSS", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::saveCustomCSS);
389+
postRequiringPermissionWithCSRF("/uploadFileForCSS", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::uploadFileForCSS);
390+
getRequiringPermissionWithCSRF("/deleteCssFile", UserRole.Permission.MANAGE_SERVER_SETTINGS, SettingsAdminRoutes::deleteCssFile);
387391

388392
path("/users", ()->{
389393
getRequiringPermission("", UserRole.Permission.MANAGE_USERS, SettingsAdminRoutes::users);
@@ -547,6 +551,16 @@ public static void main(String[] args){
547551
getLoggedIn("/simpleUserCompletions", SystemRoutes::simpleUserCompletions);
548552
get("/privacyPolicy", SystemRoutes::privacyPolicy);
549553
get("/languageChooser", SystemRoutes::languageChooser);
554+
get("/custom_desktop.css", (req, resp)->{
555+
resp.type("text/css");
556+
resp.header("cache-control", "private, max-age=604800");
557+
return Config.combinedDesktopCSS;
558+
});
559+
get("/custom_mobile.css", (req, resp)->{
560+
resp.type("text/css");
561+
resp.header("cache-control", "private, max-age=604800");
562+
return Config.combinedMobileCSS;
563+
});
550564

551565
if(Config.DEBUG){
552566
path("/debug", ()->{

src/main/java/smithereen/routes/SettingsAdminRoutes.java

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
import com.google.gson.JsonElement;
44
import com.google.gson.reflect.TypeToken;
55

6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
import java.io.File;
10+
import java.io.FileOutputStream;
11+
import java.io.IOException;
12+
import java.io.InputStream;
613
import java.net.URI;
714
import java.net.URLEncoder;
815
import java.sql.SQLException;
@@ -15,7 +22,6 @@
1522
import java.util.EnumSet;
1623
import java.util.HashMap;
1724
import java.util.HashSet;
18-
import java.util.LinkedHashMap;
1925
import java.util.List;
2026
import java.util.Locale;
2127
import java.util.Map;
@@ -25,12 +31,14 @@
2531
import java.util.stream.Collectors;
2632
import java.util.stream.IntStream;
2733

34+
import jakarta.servlet.MultipartConfigElement;
35+
import jakarta.servlet.ServletException;
36+
import jakarta.servlet.http.Part;
2837
import smithereen.ApplicationContext;
2938
import smithereen.Config;
3039
import smithereen.Mailer;
3140
import smithereen.SmithereenApplication;
3241
import smithereen.Utils;
33-
import smithereen.activitypub.objects.ActivityPubObject;
3442
import smithereen.activitypub.objects.Actor;
3543
import smithereen.exceptions.BadRequestException;
3644
import smithereen.exceptions.InternalServerErrorException;
@@ -92,6 +100,8 @@
92100
import static smithereen.Utils.*;
93101

94102
public class SettingsAdminRoutes{
103+
private static final Logger LOG=LoggerFactory.getLogger(SettingsAdminRoutes.class);
104+
95105
public static Object index(Request req, Response resp, Account self, ApplicationContext ctx){
96106
RenderedTemplateResponse model=new RenderedTemplateResponse("admin_server_info", req);
97107
Lang l=lang(req);
@@ -2113,4 +2123,94 @@ public static Object addLinksToReport(Request req, Response resp, Account self,
21132123
}
21142124
return ajaxAwareRedirect(req, resp, "/settings/admin/reports/"+report.id);
21152125
}
2126+
2127+
public static Object customCSS(Request req, Response resp, Account self, ApplicationContext ctx){
2128+
if(isMobile(req)){
2129+
resp.redirect("/settings/admin");
2130+
return "";
2131+
}
2132+
Lang l=lang(req);
2133+
File dir=new File(Config.uploadPath, "css");
2134+
String[] files=dir.exists() ? dir.list() : new String[0];
2135+
return new RenderedTemplateResponse("admin_css", req)
2136+
.with("commonCSS", Config.commonCSS)
2137+
.with("desktopCSS", Config.desktopCSS)
2138+
.with("mobileCSS", Config.mobileCSS)
2139+
.with("files", files)
2140+
.with("filesUrlPath", Config.uploadUrlPath+"/css")
2141+
.pageTitle(l.get("admin_custom_css")+" | "+l.get("menu_admin"));
2142+
}
2143+
2144+
public static Object saveCustomCSS(Request req, Response resp, Account self, ApplicationContext ctx){
2145+
Config.updateCSS(req.queryParams("common"), req.queryParams("desktop"), req.queryParams("mobile"));
2146+
2147+
if(isAjax(req))
2148+
return new WebDeltaResponse(resp).refresh();
2149+
resp.redirect(back(req));
2150+
return "";
2151+
}
2152+
2153+
public static Object uploadFileForCSS(Request req, Response resp, Account self, ApplicationContext ctx){
2154+
if(!isAjax(req))
2155+
throw new BadRequestException();
2156+
2157+
Lang l=lang(req);
2158+
2159+
File dir=new File(Config.uploadPath, "css");
2160+
try{
2161+
req.attribute("org.eclipse.jetty.multipartConfig", new MultipartConfigElement(null, 10*1024*1024, -1L, 0));
2162+
Part part=req.raw().getPart("file");
2163+
if(part==null)
2164+
throw new BadRequestException();
2165+
if(part.getSize()>10*1024*1024){
2166+
throw new UserErrorException("err_file_upload_too_large", Map.of("maxSize", l.formatFileSize(10*1024*1024)));
2167+
}
2168+
2169+
String mime=part.getContentType();
2170+
String fileName=part.getSubmittedFileName();
2171+
int index=fileName.lastIndexOf('.');
2172+
if(!mime.startsWith("image/") || index==-1)
2173+
throw new UserErrorException("err_file_upload_image_format");
2174+
2175+
String extension=fileName.substring(index+1).toLowerCase();
2176+
if(!Set.of("jpg", "jpeg", "png", "webp", "gif", "svg", "avif").contains(extension))
2177+
throw new UserErrorException("err_file_upload_image_format");
2178+
2179+
String sanitizedName=fileName.substring(0, index).replaceAll("[^a-zA-Z0-9_-]", "_")+"."+extension;
2180+
if(!dir.exists() && !dir.mkdirs())
2181+
throw new IOException("Failed to create "+dir);
2182+
2183+
File destination=new File(dir, sanitizedName);
2184+
LOG.debug("Saving to {}", destination);
2185+
try(InputStream in=part.getInputStream(); FileOutputStream out=new FileOutputStream(destination)){
2186+
copyBytes(in, out);
2187+
}
2188+
}catch(IOException | ServletException x){
2189+
throw new UserErrorException("err_file_upload", x);
2190+
}
2191+
2192+
RenderedTemplateResponse model=new RenderedTemplateResponse("admin_css", req)
2193+
.with("files", dir.list())
2194+
.with("filesUrlPath", Config.uploadUrlPath+"/css");
2195+
return new WebDeltaResponse(resp)
2196+
.setContent("cssFiles", model.renderBlock("files"))
2197+
.removeClass("cssFileButton", "loading");
2198+
}
2199+
2200+
public static Object deleteCssFile(Request req, Response resp, Account self, ApplicationContext ctx){
2201+
requireQueryParams(req, "file");
2202+
String name=req.queryParams("file");
2203+
int index=name.lastIndexOf('.');
2204+
if(index==-1)
2205+
throw new BadRequestException();
2206+
2207+
String extension=name.substring(index+1).toLowerCase();
2208+
String sanitizedName=name.substring(0, index).replaceAll("[^a-zA-Z0-9_-]", "_")+"."+extension;
2209+
File dir=new File(Config.uploadPath, "css");
2210+
File file=new File(dir, sanitizedName);
2211+
if(!file.exists() || !file.delete())
2212+
throw new ObjectNotFoundException();
2213+
2214+
return new WebDeltaResponse(resp).remove("cssFile_"+sanitizedName);
2215+
}
21162216
}

src/main/java/smithereen/templates/Templates.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,20 +149,25 @@ public static void addGlobalParamsToTemplate(Request req, RenderedTemplateRespon
149149
continue;
150150
jsLang.add("\""+key+"\":"+lang.getAsJS(key));
151151
}
152-
if(req.attribute("mobile")!=null){
152+
boolean mobile=req.attribute("mobile")!=null;
153+
if(mobile){
153154
for(String key:List.of("search", "qsearch_hint", "more_actions", "photo_open_original", "like", "add_comment",
154155
"object_X_of_Y", "delete", "delete_photo", "delete_photo_confirm", "set_photo_as_album_cover", "open_on_server_X", "report", "photo_save_to_album")){
155156
if(k!=null && k.contains(key))
156157
continue;
157158
jsLang.add("\""+key+"\":"+lang.getAsJS(key));
158159
}
160+
if(Config.mobileCSSCacheHash!=null && req.queryParams("_nocss")==null)
161+
model.with("customCSSHash", Config.mobileCSSCacheHash);
159162
}else{
160163
for(String key:List.of("photo_tagging_info", "photo_tagging_done", "photo_tag_myself", "photo_tag_select_friend", "photo_tag_not_found", "photo_delete_tag",
161164
"photo_add_tag_submit", "photo_tag_name_search")){
162165
if(k!=null && k.contains(key))
163166
continue;
164167
jsLang.add("\""+key+"\":"+lang.getAsJS(key));
165168
}
169+
if(Config.desktopCSSCacheHash!=null && req.queryParams("_nocss")==null)
170+
model.with("customCSSHash", Config.desktopCSSCacheHash);
166171
}
167172
model.with("timeZone", tz!=null ? tz : ZoneId.systemDefault()).with("jsConfig", jsConfig.toString())
168173
.with("jsLangKeys", "{"+String.join(",", jsLang)+"}")
@@ -171,7 +176,7 @@ public static void addGlobalParamsToTemplate(Request req, RenderedTemplateRespon
171176
.with("serverDomain", Config.domain)
172177
.with("serverVersion", BuildInfo.VERSION)
173178
.with("langName", lang.name)
174-
.with("isMobile", req.attribute("mobile")!=null)
179+
.with("isMobile", mobile)
175180
.with("isAjax", Utils.isAjax(req))
176181
.with("_request", req);
177182
}

src/main/java/smithereen/util/CryptoUtils.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ public static byte[] sha256(byte[] input){
7474
}
7575
}
7676

77+
public static byte[] sha1(byte[] input){
78+
try{
79+
return MessageDigest.getInstance("SHA1").digest(input);
80+
}catch(NoSuchAlgorithmException x){
81+
throw new RuntimeException(x);
82+
}
83+
}
84+
7785
public static byte[] aesGcmEncrypt(byte[] input, byte[] key){
7886
try{
7987
byte[] iv=new byte[12];

src/main/resources/langs/en/admin.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,5 +332,13 @@
332332
"report_log_added_content": "<a id=\"adminUser\">{name}</a> added this to report:",
333333
"admin_report_add_links": "Content URLs",
334334
"admin_report_add_links_one_per_line": "One per line",
335-
"admin_report_link_wrong_author": "This content was not added to this report because it was not created by the user this report is about."
335+
"admin_report_link_wrong_author": "This content was not added to this report because it was not created by the user this report is about.",
336+
"admin_custom_css": "Styles",
337+
"admin_css_explanation": "You can customize the appearance of your server's web interface here.",
338+
"admin_css_resources": "Resources",
339+
"admin_css_resources_explanation": "Upload images to use in your stylesheets.",
340+
"admin_css_add_file": "Upload a file",
341+
"admin_css_common": "Common",
342+
"admin_css_desktop": "Desktop",
343+
"admin_css_mobile": "Mobile"
336344
}

src/main/resources/langs/ru/admin.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,5 +332,13 @@
332332
"report_log_added_content": "<a id=\"adminUser\">{name}</a> {gender, select, female {добавила} other {добавил}} к жалобе:",
333333
"admin_report_add_links": "Ссылки на контент",
334334
"admin_report_add_links_one_per_line": "По одной на строку",
335-
"admin_report_link_wrong_author": "Этот объект не был добавлен в жалобу, так как он создан не тем пользователем, на которого эта жалоба."
335+
"admin_report_link_wrong_author": "Этот объект не был добавлен в жалобу, так как он создан не тем пользователем, на которого эта жалоба.",
336+
"admin_custom_css": "Стили",
337+
"admin_css_explanation": "Здесь вы можете придать индивидуальности веб-интерфейсу вашего сервера.",
338+
"admin_css_resources": "Ресурсы",
339+
"admin_css_resources_explanation": "Загрузите изображения, чтобы использовать их в своих стилях.",
340+
"admin_css_add_file": "Загрузить файл",
341+
"admin_css_common": "Общий",
342+
"admin_css_desktop": "Десктопный",
343+
"admin_css_mobile": "Мобильный"
336344
}

src/main/resources/templates/common/admin_tabbar.twig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<div class="tabbar">
33
{% if userPermissions.hasPermission('MANAGE_SERVER_SETTINGS') %}
44
<a href="/settings/admin" class="{% if tab=='general' %}selected{% endif %}">{{L('admin_server_settings')}}</a>
5+
{% if not isMobile %}<a href="/settings/admin/css" class="{% if tab=='css' %}selected{% endif %}">{{ L('admin_custom_css') }}</a>{% endif %}
56
{% endif %}
67
{% if userPermissions.hasPermission('MANAGE_SERVER_RULES') %}
78
<a href="/settings/admin/rules" class="{% if tab=='rules' %}selected{% endif %}">{{ L('admin_server_rules') }}</a>

0 commit comments

Comments
 (0)