Add staging banner

This commit is contained in:
2026-01-15 14:38:06 +01:00
parent c481b105c5
commit a2cae3f066
12 changed files with 70 additions and 9 deletions

View File

@@ -28,6 +28,7 @@ Optional:
| Variable | Description | Example Value | | Variable | Description | Example Value |
|----------|-------------|---------------| |----------|-------------|---------------|
| `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ | | `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ |
| `IMGFLOAT_IS_STAGING` | Show a staging warning banner on non-broadcast pages when set to `1` | 1 |
| `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Filesystem path to marketplace script seed directories (each containing `metadata.json`, optional `source.js`, optional `logo.png`, and optional `attachments/`) | /var/imgfloat/marketplace-scripts | | `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Filesystem path to marketplace script seed directories (each containing `metadata.json`, optional `source.js`, optional `logo.png`, and optional `attachments/`) | /var/imgfloat/marketplace-scripts |
| `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED` | Allow sysadmins to manage any channel without being listed as a channel admin | true | | `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED` | Allow sysadmins to manage any channel without being listed as a channel admin | true |
| `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch | | `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch |

View File

@@ -16,6 +16,7 @@ import dev.kruhlmann.imgfloat.service.VersionService;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@@ -34,6 +35,7 @@ public class ViewController {
private final GithubReleaseService githubReleaseService; private final GithubReleaseService githubReleaseService;
private final SystemAdministratorService systemAdministratorService; private final SystemAdministratorService systemAdministratorService;
private final long uploadLimitBytes; private final long uploadLimitBytes;
private final boolean isStaging;
public ViewController( public ViewController(
ChannelDirectoryService channelDirectoryService, ChannelDirectoryService channelDirectoryService,
@@ -44,7 +46,8 @@ public class ViewController {
AuthorizationService authorizationService, AuthorizationService authorizationService,
GithubReleaseService githubReleaseService, GithubReleaseService githubReleaseService,
SystemAdministratorService systemAdministratorService, SystemAdministratorService systemAdministratorService,
long uploadLimitBytes long uploadLimitBytes,
@Value("${IMGFLOAT_IS_STAGING:0}") String isStagingFlag
) { ) {
this.channelDirectoryService = channelDirectoryService; this.channelDirectoryService = channelDirectoryService;
this.versionService = versionService; this.versionService = versionService;
@@ -55,6 +58,7 @@ public class ViewController {
this.githubReleaseService = githubReleaseService; this.githubReleaseService = githubReleaseService;
this.systemAdministratorService = systemAdministratorService; this.systemAdministratorService = systemAdministratorService;
this.uploadLimitBytes = uploadLimitBytes; this.uploadLimitBytes = uploadLimitBytes;
this.isStaging = "1".equals(isStagingFlag);
} }
@org.springframework.web.bind.annotation.GetMapping("/") @org.springframework.web.bind.annotation.GetMapping("/")
@@ -67,9 +71,11 @@ public class ViewController {
model.addAttribute("channel", sessionUsername); model.addAttribute("channel", sessionUsername);
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername));
model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername)); model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername));
addStagingAttribute(model);
addVersionAttributes(model); addVersionAttributes(model);
return "dashboard"; return "dashboard";
} }
addStagingAttribute(model);
addVersionAttributes(model); addVersionAttributes(model);
return "index"; return "index";
} }
@@ -77,6 +83,7 @@ public class ViewController {
@org.springframework.web.bind.annotation.GetMapping("/channels") @org.springframework.web.bind.annotation.GetMapping("/channels")
public String channelDirectory(Model model) { public String channelDirectory(Model model) {
LOG.info("Rendering channel directory"); LOG.info("Rendering channel directory");
addStagingAttribute(model);
addVersionAttributes(model); addVersionAttributes(model);
return "channels"; return "channels";
} }
@@ -84,6 +91,7 @@ public class ViewController {
@org.springframework.web.bind.annotation.GetMapping("/terms") @org.springframework.web.bind.annotation.GetMapping("/terms")
public String termsOfUse(Model model) { public String termsOfUse(Model model) {
LOG.info("Rendering terms of use"); LOG.info("Rendering terms of use");
addStagingAttribute(model);
addVersionAttributes(model); addVersionAttributes(model);
return "terms"; return "terms";
} }
@@ -91,6 +99,7 @@ public class ViewController {
@org.springframework.web.bind.annotation.GetMapping("/privacy") @org.springframework.web.bind.annotation.GetMapping("/privacy")
public String privacyPolicy(Model model) { public String privacyPolicy(Model model) {
LOG.info("Rendering privacy policy"); LOG.info("Rendering privacy policy");
addStagingAttribute(model);
addVersionAttributes(model); addVersionAttributes(model);
return "privacy"; return "privacy";
} }
@@ -98,6 +107,7 @@ public class ViewController {
@org.springframework.web.bind.annotation.GetMapping("/cookies") @org.springframework.web.bind.annotation.GetMapping("/cookies")
public String cookiePolicy(Model model) { public String cookiePolicy(Model model) {
LOG.info("Rendering cookie policy"); LOG.info("Rendering cookie policy");
addStagingAttribute(model);
addVersionAttributes(model); addVersionAttributes(model);
return "cookies"; return "cookies";
} }
@@ -116,6 +126,7 @@ public class ViewController {
throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings"); throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings");
} }
model.addAttribute("initialSysadmin", systemAdministratorService.getInitialSysadmin()); model.addAttribute("initialSysadmin", systemAdministratorService.getInitialSysadmin());
addStagingAttribute(model);
return "settings"; return "settings";
} }
@@ -144,6 +155,7 @@ public class ViewController {
LOG.error("Failed to serialize settings for admin view", e); LOG.error("Failed to serialize settings for admin view", e);
throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings"); throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings");
} }
addStagingAttribute(model);
return "admin"; return "admin";
} }
@@ -167,4 +179,8 @@ public class ViewController {
model.addAttribute("buildCommitShort", gitInfoService.getShortCommitSha()); model.addAttribute("buildCommitShort", gitInfoService.getShortCommitSha());
model.addAttribute("buildCommitUrl", gitInfoService.getCommitUrl()); model.addAttribute("buildCommitUrl", gitInfoService.getCommitUrl());
} }
private void addStagingAttribute(Model model) {
model.addAttribute("isStaging", isStaging);
}
} }

View File

@@ -22,6 +22,34 @@ body {
padding: 0; padding: 0;
} }
body.has-staging-banner {
padding-top: 44px;
}
.staging-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 8px 16px;
text-align: center;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 700;
background: repeating-linear-gradient(135deg, #111827 0 18px, #facc15 18px 36px);
}
.staging-banner span {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
background: #111827;
color: #facc15;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.landing-body { .landing-body {
min-height: 100vh; min-height: 100vh;
background: background:

View File

@@ -29,7 +29,8 @@
<script src="/js/vendor/OBJLoader.js"></script> <script src="/js/vendor/OBJLoader.js"></script>
<script src="/js/csrf.js"></script> <script src="/js/csrf.js"></script>
</head> </head>
<body class="admin-body"> <body class="admin-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="admin-frame"> <div class="admin-frame">
<header class="admin-topbar"> <header class="admin-topbar">
<div class="topbar-left"> <div class="topbar-left">

View File

@@ -6,7 +6,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body> <body th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<main class="landing"> <main class="landing">
<header class="landing-header"> <header class="landing-header">
<div class="brand"> <div class="brand">

View File

@@ -8,7 +8,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="dashboard-body"> <body class="dashboard-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="dashboard-shell"> <div class="dashboard-shell">
<header class="dashboard-topbar"> <header class="dashboard-topbar">
<div class="brand"> <div class="brand">

View File

@@ -6,7 +6,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="error-body"> <body class="error-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="error-shell"> <div class="error-shell">
<header class="error-header"> <header class="error-header">
<div class="brand"> <div class="brand">

View File

@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="banner" th:if="${isStaging}" class="staging-banner" role="status">
<span>Staging environment</span>
</div>
</body>
</html>

View File

@@ -6,7 +6,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="landing-body"> <body class="landing-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="landing"> <div class="landing">
<header class="landing-header"> <header class="landing-header">
<div class="brand"> <div class="brand">

View File

@@ -6,7 +6,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body> <body th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<main class="landing"> <main class="landing">
<header class="landing-header"> <header class="landing-header">
<div class="brand"> <div class="brand">

View File

@@ -18,7 +18,8 @@
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script src="/js/csrf.js"></script> <script src="/js/csrf.js"></script>
</head> </head>
<body class="settings-body"> <body class="settings-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="settings-shell"> <div class="settings-shell">
<header class="settings-header"> <header class="settings-header">
<div class="brand"> <div class="brand">

View File

@@ -6,7 +6,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body> <body th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<main class="landing"> <main class="landing">
<header class="landing-header"> <header class="landing-header">
<div class="brand"> <div class="brand">