Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [Unreleased]

## [0.4.0-beta.10] - 2026-10-09

### Fixed
- Deployment sign-in and email confirmation pages show FlatRun branding
- Deployment access links go to the requested email address

## [0.4.0-beta.9] - 2026-09-28

Ninth beta of the Albacore release, adding managed migration and recovery workflows.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0-beta.9
0.4.0-beta.10
26 changes: 21 additions & 5 deletions internal/api/access_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ type accessEmailSender interface {
SendEmailTo(string, string, notify.Notification) error
}

const accessPageStyles = `<style>
:root{color-scheme:light;font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f8fafc;color:#1e293b}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#eff6ff 0,#f8fafc 42%,#f1f5f9 100%)}
.shell{width:min(420px,100%)}.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:24px;color:#0f172a;font-size:22px;font-weight:750;letter-spacing:-.03em}
.brand-mark{width:38px;height:38px;display:grid;align-content:center;gap:4px;padding:8px;border-radius:10px;background:#2563eb;box-shadow:0 8px 20px rgba(37,99,235,.24);transform:rotate(-5deg)}
.brand-mark span{display:block;height:5px;border:2px solid #fff;border-radius:2px}.card{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:32px;box-shadow:0 18px 45px rgba(15,23,42,.09)}
h1{font-size:25px;line-height:1.25;letter-spacing:-.025em;margin:0 0 10px;color:#0f172a}p{color:#64748b;line-height:1.6;margin:0 0 26px}label{display:block;font-size:14px;font-weight:650;margin-bottom:8px;color:#334155}
input{width:100%;padding:12px 13px;border:1px solid #cbd5e1;border-radius:9px;background:#fff;color:#0f172a;font:inherit;outline:0;transition:border-color .15s,box-shadow .15s}input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.16)}
button{width:100%;margin-top:16px;padding:12px 16px;border:0;border-radius:9px;background:#2563eb;color:#fff;font:inherit;font-weight:650;cursor:pointer;transition:background .15s,transform .15s}button:hover{background:#1d4ed8}button:active{transform:translateY(1px)}
.note{margin:22px 0 0;text-align:center;font-size:12px;color:#94a3b8}@media(max-width:480px){body{padding:16px}.card{padding:25px 22px}}
</style>`

const accessBrand = `<div class="brand" aria-label="FlatRun"><span class="brand-mark" aria-hidden="true"><span></span><span></span><span></span></span><span>FlatRun</span></div>`

func accessPage(title, content string) string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>` + title + ` | FlatRun</title>` + accessPageStyles + `</head><body><main class="shell">` + accessBrand + `<section class="card">` + content + `</section><p class="note">Protected by FlatRun</p></main></body></html>`
}

func (s *Server) checkApplicationAccess(c *gin.Context) {
policy, ok := s.applicationAccessPolicy(c.GetHeader("X-Original-Host"), c.GetHeader("X-Original-URI"))
if !ok {
Expand All @@ -35,10 +53,8 @@ func (s *Server) checkApplicationAccess(c *gin.Context) {
func (s *Server) applicationAccessLogin(c *gin.Context) {
returnPath := access.SafeReturn(c.Query("return"))
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in</title><style>body{font-family:system-ui,sans-serif;background:#f6f7f9;color:#17202a;margin:0;display:grid;place-items:center;min-height:100vh}.card{background:#fff;border:1px solid #dfe3e8;border-radius:16px;padding:32px;width:min(380px,calc(100%% - 48px));box-shadow:0 12px 32px #17202a14}h1{font-size:24px;margin:0 0 8px}p{color:#59636e;margin:0 0 24px}label{display:block;font-weight:600;margin-bottom:8px}input{box-sizing:border-box;width:100%%;padding:12px;border:1px solid #b8c0c8;border-radius:8px;font:inherit}button{width:100%%;margin-top:16px;padding:12px;border:0;border-radius:8px;background:#2563eb;color:white;font:inherit;font-weight:600}</style></head>
<body><main class="card"><h1>Verify your email</h1><p>We will email you a secure sign-in link.</p><form method="post" action="/_flatrun/access/request"><input type="hidden" name="return" value="%s"><label for="email">Email address</label><input id="email" name="email" type="email" autocomplete="email" required><button type="submit">Email me a sign-in link</button></form></main></body></html>`, html.EscapeString(returnPath))
content := fmt.Sprintf(`<h1>Verify your email</h1><p>Enter your email address to receive a secure sign-in link.</p><form method="post" action="/_flatrun/access/request"><input type="hidden" name="return" value="%s"><label for="email">Email address</label><input id="email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required autofocus><button type="submit">Email me a sign-in link</button></form>`, html.EscapeString(returnPath))
c.String(http.StatusOK, accessPage("Sign in", content))
}

func (s *Server) requestApplicationAccess(c *gin.Context) {
Expand All @@ -62,7 +78,7 @@ func (s *Server) requestApplicationAccess(c *gin.Context) {
}
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusAccepted, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Check your email</title></head><body><main><h1>Check your email</h1><p>If this address is allowed, a sign-in link is on its way.</p></main></body></html>`)
c.String(http.StatusAccepted, accessPage("Check your email", `<h1>Check your email</h1><p>If this address is allowed, a secure sign-in link is on its way.</p>`))
}

func (s *Server) getAccessEmailTargets(c *gin.Context) {
Expand Down
25 changes: 25 additions & 0 deletions internal/api/access_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,29 @@ func TestApplicationAccessLoginRejectsUnsafeReturnPath(t *testing.T) {
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `name="return" value="/"`) {
t.Fatalf("unsafe return path was accepted: %d %s", response.Code, response.Body.String())
}
for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Verify your email"} {
if !strings.Contains(response.Body.String(), expected) {
t.Fatalf("access page is missing %q", expected)
}
}
}

func TestApplicationAccessConfirmationUsesFlatRunBranding(t *testing.T) {
server := &Server{}
router := gin.New()
router.POST("/api/access/request", server.requestApplicationAccess)
request := httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{
"email": {"person@example.com"}, "return": {"/"},
}.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusAccepted {
t.Fatalf("confirmation response = %d", response.Code)
}
for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Check your email"} {
if !strings.Contains(response.Body.String(), expected) {
t.Fatalf("confirmation page is missing %q", expected)
}
}
}
2 changes: 1 addition & 1 deletion internal/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "FlatRun Agent API",
"description": "Generated from the agent's routes and the types its handlers bind and return.",
"version": "0.4.0-beta.9"
"version": "0.4.0-beta.10"
},
"paths": {
"/api/access/check": {
Expand Down
5 changes: 5 additions & 0 deletions internal/notify/notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ func (s *Service) SendEmailTo(targetID, recipient string, notification Notificat
return fmt.Errorf("notification target is not SMTP")
}
query := parsed.Query()
for key := range query {
if strings.EqualFold(key, "to") || strings.EqualFold(key, "toaddresses") {
query.Del(key)
}
}
query.Set("to", recipient)
parsed.RawQuery = query.Encode()
return s.deliver(parsed.String(), notification)
Expand Down
29 changes: 29 additions & 0 deletions internal/notify/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/nicholas-fedor/shoutrrr/pkg/router"
shoutrrrsmtp "github.com/nicholas-fedor/shoutrrr/pkg/services/email/smtp"
)

Expand Down Expand Up @@ -45,6 +46,34 @@ func TestSendEmailToOverridesTheConfiguredRecipient(t *testing.T) {
}
}

func TestSendEmailToOverridesSMTPGeneratedRecipients(t *testing.T) {
config := &shoutrrrsmtp.Config{
Host: "mail.example", Port: 25, FromAddress: "ops@example.com",
ToAddresses: []string{"ops@example.com"},
}
service := NewService(t.TempDir())
if err := service.Save(Config{Targets: []Target{{
ID: "access", Name: "Access", URL: config.GetURL().String(), Enabled: true,
}}}); err != nil {
t.Fatal(err)
}
service.send = func(rawURL, _ string) error {
serviceRouter := &router.ServiceRouter{}
delivery, err := serviceRouter.Locate(rawURL)
if err != nil {
return err
}
recipients := delivery.(*shoutrrrsmtp.Service).Config.ToAddresses
if len(recipients) != 1 || recipients[0] != "visitor@example.com" {
t.Fatalf("SMTP recipients = %v", recipients)
}
return nil
}
if err := service.SendEmailTo("access", "visitor@example.com", Notification{Title: "Sign in"}); err != nil {
t.Fatal(err)
}
}

func TestEmailMessageEmbedsImagesReferencedByContentID(t *testing.T) {
png := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
message, err := buildEmailMessage(Notification{
Expand Down
Loading