Compare commits
6 Commits
scalar-api
...
user-heade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e755a73f0 | ||
|
|
857f5742b9 | ||
|
|
099cad02b8 | ||
|
|
9b1be517ea | ||
|
|
b67ffa4f5f | ||
|
|
46cb9f2b41 |
@@ -37,6 +37,7 @@ ADD go.mod go.sum main.go ./
|
|||||||
ADD ./client ./client
|
ADD ./client ./client
|
||||||
ADD ./cmd ./cmd
|
ADD ./cmd ./cmd
|
||||||
ADD ./log ./log
|
ADD ./log ./log
|
||||||
|
ADD ./payments ./payments
|
||||||
ADD ./server ./server
|
ADD ./server ./server
|
||||||
ADD ./user ./user
|
ADD ./user ./user
|
||||||
ADD ./util ./util
|
ADD ./util ./util
|
||||||
|
|||||||
37
cmd/serve.go
37
cmd/serve.go
@@ -95,6 +95,8 @@ var flagsServe = append(
|
|||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-user-header", Aliases: []string{"auth_user_header"}, EnvVars: []string{"NTFY_AUTH_USER_HEADER"}, Value: "", Usage: "if set (along with behind-proxy and auth-file), trust this header to contain the authenticated username (e.g. X-Forwarded-User, Remote-User)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-logout-url", Aliases: []string{"auth_logout_url"}, EnvVars: []string{"NTFY_AUTH_LOGOUT_URL"}, Value: "", Usage: "URL to redirect to when logging out in proxy auth mode (e.g. https://auth.example.com/logout)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
|
||||||
@@ -206,6 +208,8 @@ func execServe(c *cli.Context) error {
|
|||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
||||||
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
||||||
|
authUserHeader := c.String("auth-user-header")
|
||||||
|
authLogoutURL := c.String("auth-logout-url")
|
||||||
stripeSecretKey := c.String("stripe-secret-key")
|
stripeSecretKey := c.String("stripe-secret-key")
|
||||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||||
billingContact := c.String("billing-contact")
|
billingContact := c.String("billing-contact")
|
||||||
@@ -313,7 +317,8 @@ func execServe(c *cli.Context) error {
|
|||||||
} else if u.Path != "" {
|
} else if u.Path != "" {
|
||||||
return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
|
return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
|
||||||
}
|
}
|
||||||
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
}
|
||||||
|
if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||||
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||||
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
||||||
return errors.New("if set, upstream-base-url must not end with a slash (/)")
|
return errors.New("if set, upstream-base-url must not end with a slash (/)")
|
||||||
@@ -338,12 +343,21 @@ func execServe(c *cli.Context) error {
|
|||||||
if messageSizeLimit > 5*1024*1024 {
|
if messageSizeLimit > 5*1024*1024 {
|
||||||
return errors.New("message-size-limit cannot be higher than 5M")
|
return errors.New("message-size-limit cannot be higher than 5M")
|
||||||
}
|
}
|
||||||
} else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
|
}
|
||||||
|
if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
|
||||||
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
|
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
|
||||||
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
} else if behindProxy && proxyForwardedHeader == "" {
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
|
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
|
||||||
|
} else if authUserHeader != "" && !behindProxy {
|
||||||
|
return errors.New("auth-user-header requires behind-proxy to be set")
|
||||||
|
} else if authUserHeader != "" && authFile == "" {
|
||||||
|
return errors.New("auth-user-header requires auth-file to be set")
|
||||||
|
} else if authUserHeader != "" && enableLogin {
|
||||||
|
return errors.New("auth-user-header cannot be used with enable-login")
|
||||||
|
} else if authUserHeader != "" && enableSignup {
|
||||||
|
return errors.New("auth-user-header cannot be used with enable-signup")
|
||||||
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
|
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
|
||||||
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||||
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||||
@@ -412,6 +426,15 @@ func execServe(c *cli.Context) error {
|
|||||||
payments.Setup(stripeSecretKey)
|
payments.Setup(stripeSecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse Twilio call format template
|
||||||
|
var twilioCallFormatTemplate *template.Template
|
||||||
|
if twilioCallFormat != "" {
|
||||||
|
twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add default forbidden topics
|
// Add default forbidden topics
|
||||||
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
|
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
|
||||||
|
|
||||||
@@ -437,6 +460,8 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.AuthUsers = authUsers
|
conf.AuthUsers = authUsers
|
||||||
conf.AuthAccess = authAccess
|
conf.AuthAccess = authAccess
|
||||||
conf.AuthTokens = authTokens
|
conf.AuthTokens = authTokens
|
||||||
|
conf.AuthUserHeader = authUserHeader
|
||||||
|
conf.AuthLogoutURL = authLogoutURL
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
@@ -459,13 +484,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.TwilioAuthToken = twilioAuthToken
|
conf.TwilioAuthToken = twilioAuthToken
|
||||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||||
conf.TwilioVerifyService = twilioVerifyService
|
conf.TwilioVerifyService = twilioVerifyService
|
||||||
if twilioCallFormat != "" {
|
conf.TwilioCallFormat = twilioCallFormatTemplate
|
||||||
tmpl, err := template.New("twiml").Parse(twilioCallFormat)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
|
|
||||||
}
|
|
||||||
conf.TwilioCallFormat = tmpl
|
|
||||||
}
|
|
||||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||||
conf.MessageDelayMax = messageDelayLimit
|
conf.MessageDelayMax = messageDelayLimit
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ntfy API Reference</title>
|
|
||||||
<link rel="icon" type="image/png" href="/static/img/favicon.png">
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header matching docs.ntfy.sh */
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(to right, #317f6f, #14b8a6);
|
|
||||||
padding: 16px 20px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-text {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-text h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
color: white;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-text p {
|
|
||||||
font-size: 13px;
|
|
||||||
margin: 2px 0 0 0;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-links {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-links a {
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.9;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-links a:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scalar container */
|
|
||||||
#scalar-api-reference {
|
|
||||||
width: 100%;
|
|
||||||
min-height: calc(100vh - 100px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide Share and Generate SDKs menu items */
|
|
||||||
[data-scalar-menu-item="share"],
|
|
||||||
[data-scalar-menu-item="sdk"],
|
|
||||||
.scalar-card-header-actions button[title="Share"],
|
|
||||||
.scalar-card-header-actions button[title="Generate SDK"],
|
|
||||||
button:has(> span:contains("Share")),
|
|
||||||
button:has(> span:contains("SDK")) {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header-content {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.header-links {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-content">
|
|
||||||
<a href="/" style="display: flex; align-items: center; text-decoration: none;">
|
|
||||||
<img src="/static/img/ntfy.png" width="35" height="35" alt="ntfy logo" style="margin-right: 10px;">
|
|
||||||
</a>
|
|
||||||
<div class="header-text">
|
|
||||||
<h1>ntfy</h1>
|
|
||||||
<p>API Reference</p>
|
|
||||||
</div>
|
|
||||||
<div class="header-links">
|
|
||||||
<a href="/">Documentation Home</a>
|
|
||||||
<a href="https://ntfy.sh">ntfy.sh</a>
|
|
||||||
<a href="https://github.com/binwiederhier/ntfy">GitHub</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Scalar API Reference -->
|
|
||||||
<div id="scalar-api-reference"></div>
|
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="./scalar-style.css" />
|
|
||||||
<script src="./scalar-standalone.js"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function initScalar() {
|
|
||||||
const targetElement = document.getElementById('scalar-api-reference');
|
|
||||||
|
|
||||||
if (!targetElement) {
|
|
||||||
setTimeout(initScalar, 50);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof Scalar === 'undefined' || typeof Scalar.createApiReference !== 'function') {
|
|
||||||
setTimeout(initScalar, 50);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
url: './openapi.yaml',
|
|
||||||
layout: 'modern',
|
|
||||||
theme: 'default',
|
|
||||||
customCss: `
|
|
||||||
/* Hide Share and Generate SDKs menu items in dropdowns */
|
|
||||||
[data-testid="share-button"],
|
|
||||||
[data-testid="sdk-button"],
|
|
||||||
.context-menu-item:has(svg[data-icon="share"]),
|
|
||||||
.context-menu-item:has(svg[data-icon="sdk"]),
|
|
||||||
.dropdown-item:has(span:contains("Share")),
|
|
||||||
.dropdown-item:has(span:contains("SDK")),
|
|
||||||
.scalar-dropdown-item[data-action="share"],
|
|
||||||
.scalar-dropdown-item[data-action="generate-sdk"],
|
|
||||||
button[aria-label="Share"],
|
|
||||||
button[aria-label="Generate SDK"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
configuration: {
|
|
||||||
hideSidebar: false,
|
|
||||||
hideSearch: false,
|
|
||||||
hideModels: false,
|
|
||||||
hideDownloadButton: false,
|
|
||||||
hideTabs: false,
|
|
||||||
hideServerUrl: false,
|
|
||||||
hideInfo: false,
|
|
||||||
darkMode: false,
|
|
||||||
withDefaultFonts: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
Scalar.createApiReference('#scalar-api-reference', config);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing Scalar:', error);
|
|
||||||
targetElement.innerHTML = '<div style="padding: 20px; color: red;">Error loading API reference: ' + error.message + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initScalar);
|
|
||||||
} else {
|
|
||||||
setTimeout(initScalar, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
setTimeout(initScalar, 200);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1619,7 +1619,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: "myhome",
|
topic: "myhome",
|
||||||
message: "Somebody retweeted your tweet.",
|
message": "Somebody retweeted your tweet.",
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
action: "view",
|
action: "view",
|
||||||
@@ -1879,7 +1879,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: "wifey",
|
topic: "wifey",
|
||||||
message: "Your wife requested you send a picture of yourself.",
|
message": "Your wife requested you send a picture of yourself.",
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
"action": "broadcast",
|
"action": "broadcast",
|
||||||
@@ -2154,7 +2154,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: "myhome",
|
topic: "myhome",
|
||||||
message: "Garage door has been open for 15 minutes. Close it?",
|
"message": "Garage door has been open for 15 minutes. Close it?",
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
"action": "http",
|
"action": "http",
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ nav:
|
|||||||
- "Other things":
|
- "Other things":
|
||||||
- "FAQs": faq.md
|
- "FAQs": faq.md
|
||||||
- "Examples": examples.md
|
- "Examples": examples.md
|
||||||
- "API Reference": /api/
|
|
||||||
- "Integrations + projects": integrations.md
|
- "Integrations + projects": integrations.md
|
||||||
- "Release notes": releases.md
|
- "Release notes": releases.md
|
||||||
- "Emojis 🥳 🎉": emojis.md
|
- "Emojis 🥳 🎉": emojis.md
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ type Config struct {
|
|||||||
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
|
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
|
||||||
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
|
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
|
||||||
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
|
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
|
||||||
|
AuthUserHeader string // Header to read the authenticated user from, if BehindProxy is true (e.g. X-Forwarded-User, Remote-User)
|
||||||
|
AuthLogoutURL string // URL to redirect to when logging out in proxy auth mode (e.g. https://auth.example.com/logout)
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
StripePriceCacheDuration time.Duration
|
StripePriceCacheDuration time.Duration
|
||||||
@@ -263,6 +265,8 @@ func NewConfig() *Config {
|
|||||||
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
|
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
|
||||||
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
|
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
|
||||||
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
|
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
|
||||||
|
AuthUserHeader: "", // Header to read the authenticated user from (requires behind-proxy and auth-file)
|
||||||
|
AuthLogoutURL: "", // URL to redirect to when logging out in proxy auth mode
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
StripeWebhookKey: "",
|
StripeWebhookKey: "",
|
||||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
|
|||||||
@@ -620,6 +620,10 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) configResponse() *apiConfigResponse {
|
func (s *Server) configResponse() *apiConfigResponse {
|
||||||
|
authMode := ""
|
||||||
|
if s.config.AuthUserHeader != "" {
|
||||||
|
authMode = "proxy"
|
||||||
|
}
|
||||||
return &apiConfigResponse{
|
return &apiConfigResponse{
|
||||||
BaseURL: "", // Will translate to window.location.origin
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
AppRoot: s.config.WebRoot,
|
AppRoot: s.config.WebRoot,
|
||||||
@@ -635,6 +639,8 @@ func (s *Server) configResponse() *apiConfigResponse {
|
|||||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||||
DisallowedTopics: s.config.DisallowedTopics,
|
DisallowedTopics: s.config.DisallowedTopics,
|
||||||
ConfigHash: s.config.Hash(),
|
ConfigHash: s.config.Hash(),
|
||||||
|
AuthMode: authMode,
|
||||||
|
AuthLogoutURL: s.config.AuthLogoutURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,6 +673,11 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visito
|
|||||||
// handleStatic returns all static resources (excluding the docs), including the web app
|
// handleStatic returns all static resources (excluding the docs), including the web app
|
||||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
r.URL.Path = webSiteDir + r.URL.Path
|
r.URL.Path = webSiteDir + r.URL.Path
|
||||||
|
// Prevent caching of HTML files to ensure auth proxies can intercept unauthenticated requests.
|
||||||
|
// Static hashed assets (JS, CSS, images) can still be cached normally.
|
||||||
|
if strings.HasSuffix(r.URL.Path, ".html") {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2140,6 +2151,24 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
|||||||
if s.userManager == nil {
|
if s.userManager == nil {
|
||||||
return vip, nil
|
return vip, nil
|
||||||
}
|
}
|
||||||
|
// Check for proxy-forwarded user header (requires behind-proxy and auth-user-header to be set)
|
||||||
|
if s.config.BehindProxy && s.config.AuthUserHeader != "" {
|
||||||
|
if username := strings.TrimSpace(r.Header.Get(s.config.AuthUserHeader)); username != "" {
|
||||||
|
u, err := s.userManager.User(username)
|
||||||
|
if err != nil {
|
||||||
|
logr(r).Err(err).Debug("User from auth-user-header not found")
|
||||||
|
return vip, errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
if u.Deleted {
|
||||||
|
logr(r).Debug("User from auth-user-header is deleted")
|
||||||
|
return vip, errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
logr(r).Debug("User from header found")
|
||||||
|
return s.visitor(ip, u), nil
|
||||||
|
}
|
||||||
|
// If auth-user-header is set, but no user was provided, return unauthorized
|
||||||
|
return vip, errHTTPUnauthorized
|
||||||
|
}
|
||||||
header, err := readAuthHeader(r)
|
header, err := readAuthHeader(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vip, err
|
return vip, err
|
||||||
|
|||||||
@@ -124,6 +124,27 @@
|
|||||||
# proxy-forwarded-header: "X-Forwarded-For"
|
# proxy-forwarded-header: "X-Forwarded-For"
|
||||||
# proxy-trusted-hosts:
|
# proxy-trusted-hosts:
|
||||||
|
|
||||||
|
# If set (along with behind-proxy and auth-file), trust this header to contain the authenticated
|
||||||
|
# username. This is useful when running ntfy behind an authentication proxy like Authelia,
|
||||||
|
# Authentik, or Caddy Security that handles authentication and forwards the user identity.
|
||||||
|
#
|
||||||
|
# Common header names:
|
||||||
|
# - X-Forwarded-User (Authelia default)
|
||||||
|
# - Remote-User (common convention)
|
||||||
|
# - X-Remote-User
|
||||||
|
#
|
||||||
|
# IMPORTANT: Only enable this if you trust the proxy to authenticate users. The header value
|
||||||
|
# is trusted unconditionally when behind-proxy is also set. Users must be pre-provisioned in
|
||||||
|
# the ntfy database (via auth-file); they are not auto-created.
|
||||||
|
#
|
||||||
|
# auth-user-header:
|
||||||
|
|
||||||
|
# If auth-user-header is set, this is the URL to redirect users to when they click logout.
|
||||||
|
# This is typically the logout URL of your authentication proxy (e.g. Authelia, Authentik).
|
||||||
|
# If not set, the logout button will be hidden in the web UI when using proxy auth.
|
||||||
|
#
|
||||||
|
# auth-logout-url:
|
||||||
|
|
||||||
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||||
# are "attachment-cache-dir" and "base-url".
|
# are "attachment-cache-dir" and "base-url".
|
||||||
#
|
#
|
||||||
@@ -160,7 +181,7 @@
|
|||||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||||
#
|
#
|
||||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported.
|
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
||||||
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||||
#
|
#
|
||||||
# - smtp-sender-addr is the hostname:port of the SMTP server
|
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||||
@@ -198,8 +219,8 @@
|
|||||||
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
||||||
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
|
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
|
||||||
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
|
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
|
||||||
# - web-push-startup-queries is an optional list of queries to run on startup
|
# - web-push-startup-queries is an optional list of queries to run on startup`
|
||||||
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d)
|
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d`)
|
||||||
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
|
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
|
||||||
#
|
#
|
||||||
# web-push-public-key:
|
# web-push-public-key:
|
||||||
@@ -280,7 +301,7 @@
|
|||||||
#
|
#
|
||||||
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
||||||
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
||||||
# if you exceed the upstream rate limits, or the upstream server requires authentication.
|
# if you exceed the upstream rate limits, or the uptream server requires authentication.
|
||||||
#
|
#
|
||||||
# upstream-base-url:
|
# upstream-base-url:
|
||||||
# upstream-access-token:
|
# upstream-access-token:
|
||||||
|
|||||||
@@ -2398,6 +2398,102 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
|
|||||||
require.Equal(t, "2001:db8:3333::1", v.ip.String())
|
require.Equal(t, "2001:db8:3333::1", v.ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_AuthUserHeader_Success(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.AuthUserHeader = "X-Forwarded-User"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
|
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
|
||||||
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
|
r.Header.Set("X-Forwarded-User", "phil")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotNil(t, v.User())
|
||||||
|
require.Equal(t, "phil", v.User().Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_AuthUserHeader_UserNotFound(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.AuthUserHeader = "X-Forwarded-User"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
|
||||||
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
|
r.Header.Set("X-Forwarded-User", "unknown-user")
|
||||||
|
_, err := s.maybeAuthenticate(r)
|
||||||
|
require.Equal(t, errHTTPUnauthorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_AuthUserHeader_NoHeader_ReturnsUnauthorized(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.AuthUserHeader = "X-Forwarded-User"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
|
// No X-Forwarded-User header, even with Authorization header -> unauthorized
|
||||||
|
// When auth-user-header is configured, the header MUST be present
|
||||||
|
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
|
||||||
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
|
r.Header.Set("Authorization", util.BasicAuth("phil", "phil"))
|
||||||
|
_, err := s.maybeAuthenticate(r)
|
||||||
|
require.Equal(t, errHTTPUnauthorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_AuthUserHeader_NoHeader_NoAuthReturnsUnauthorized(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.AuthUserHeader = "X-Forwarded-User"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// No X-Forwarded-User header and no Authorization header -> unauthorized
|
||||||
|
// When auth-user-header is configured, the header MUST be present
|
||||||
|
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
|
||||||
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
|
_, err := s.maybeAuthenticate(r)
|
||||||
|
require.Equal(t, errHTTPUnauthorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_AuthUserHeader_NotBehindProxy(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BehindProxy = false // Auth user header should be ignored if not behind proxy
|
||||||
|
c.AuthUserHeader = "X-Forwarded-User"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
|
// Header is present but should be ignored since behind-proxy is false
|
||||||
|
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
|
||||||
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
|
r.Header.Set("X-Forwarded-User", "phil")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, v.User()) // Should be anonymous since header is ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_AuthUserHeader_RemoteUser(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.AuthUserHeader = "Remote-User" // Common alternative header name
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
|
||||||
|
|
||||||
|
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
|
||||||
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
|
r.Header.Set("Remote-User", "admin")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotNil(t, v.User())
|
||||||
|
require.Equal(t, "admin", v.User().Name)
|
||||||
|
require.True(t, v.User().IsAdmin())
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
count := 50000
|
count := 50000
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ type apiConfigResponse struct {
|
|||||||
WebPushPublicKey string `json:"web_push_public_key"`
|
WebPushPublicKey string `json:"web_push_public_key"`
|
||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
ConfigHash string `json:"config_hash"`
|
ConfigHash string `json:"config_hash"`
|
||||||
|
AuthMode string `json:"auth_mode,omitempty"` // "proxy" if auth-user-header is set, empty otherwise
|
||||||
|
AuthLogoutURL string `json:"auth_logout_url,omitempty"` // URL to redirect to on logout (only for proxy auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountBillingPrices struct {
|
type apiAccountBillingPrices struct {
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ var config = {
|
|||||||
web_push_public_key: "",
|
web_push_public_key: "",
|
||||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
||||||
config_hash: "dev", // Placeholder for development; actual value is generated server-side
|
config_hash: "dev", // Placeholder for development; actual value is generated server-side
|
||||||
|
auth_mode: "", // "proxy" if auth-user-header is set, empty otherwise
|
||||||
|
auth_logout_url: "", // URL to redirect to on logout (only for proxy auth)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
|
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
import { registerRoute } from "workbox-routing";
|
||||||
import { NetworkFirst } from "workbox-strategies";
|
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
|
||||||
|
import { CacheableResponsePlugin } from "workbox-cacheable-response";
|
||||||
|
import { ExpirationPlugin } from "workbox-expiration";
|
||||||
import { clientsClaim } from "workbox-core";
|
import { clientsClaim } from "workbox-core";
|
||||||
import { dbAsync } from "../src/app/db";
|
import { dbAsync } from "../src/app/db";
|
||||||
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
||||||
@@ -337,27 +339,42 @@ clientsClaim();
|
|||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
if (!import.meta.env.DEV) {
|
if (!import.meta.env.DEV) {
|
||||||
// we need the app_root setting, so we import the config.js file from the go server
|
// Use NetworkFirst for navigation requests. This ensures that auth proxies (like Authelia)
|
||||||
// this does NOT include the same base_url as the web app running in a window,
|
// can intercept unauthenticated requests, while still providing offline fallback.
|
||||||
// since we don't have access to `window` like in `src/app/config.js`
|
// The 3-second timeout means if the network is slow/unavailable, cached HTML is served.
|
||||||
self.importScripts("/config.js");
|
|
||||||
|
|
||||||
// this is the fallback single-page-app route, matching vite.config.js PWA config,
|
|
||||||
// and is served by the go web server. It is needed for the single-page-app to work.
|
|
||||||
// https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route
|
|
||||||
registerRoute(
|
registerRoute(
|
||||||
new NavigationRoute(createHandlerBoundToURL("/app.html"), {
|
({ request }) => request.mode === "navigate",
|
||||||
allowlist: [
|
new NetworkFirst({
|
||||||
// the app root itself, could be /, or not
|
cacheName: "html-cache",
|
||||||
new RegExp(`^${config.app_root}$`),
|
networkTimeoutSeconds: 3,
|
||||||
|
plugins: [new CacheableResponsePlugin({ statuses: [200] }), new ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 60 })],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache static assets (JS, CSS, images, fonts) with StaleWhileRevalidate for better performance.
|
||||||
|
// Serves cached version immediately while fetching fresh version in the background.
|
||||||
|
registerRoute(
|
||||||
|
({ request }) =>
|
||||||
|
request.destination === "script" ||
|
||||||
|
request.destination === "style" ||
|
||||||
|
request.destination === "image" ||
|
||||||
|
request.destination === "font",
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: "assets-cache",
|
||||||
|
plugins: [
|
||||||
|
new CacheableResponsePlugin({ statuses: [200] }),
|
||||||
|
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// the manifest excludes config.js (see vite.config.js) since the dist-file differs from the
|
// Handle config.js with NetworkFirst. The manifest excludes it (see vite.config.js) since
|
||||||
// actual config served by the go server. this adds it back with `NetworkFirst`, so that the
|
// the dist-file differs from the actual config served by the go server.
|
||||||
// most recent config from the go server is cached, but the app still works if the network
|
registerRoute(
|
||||||
// is unavailable. this is important since there's no "refresh" button in the installed pwa
|
({ url }) => url.pathname === "/config.js",
|
||||||
// to force a reload.
|
new NetworkFirst({
|
||||||
registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst());
|
cacheName: "config-cache",
|
||||||
|
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
withBasicAuth,
|
withBasicAuth,
|
||||||
withBearerAuth,
|
withBearerAuth,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import config from "./config";
|
||||||
import session from "./Session";
|
import session from "./Session";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
@@ -341,7 +342,18 @@ class AccountApi {
|
|||||||
|
|
||||||
async sync() {
|
async sync() {
|
||||||
try {
|
try {
|
||||||
if (!session.token()) {
|
// For proxy auth, detect user from /v1/account if no session exists
|
||||||
|
if (config.auth_mode === AuthMode.PROXY && !session.exists()) {
|
||||||
|
console.log(`[AccountApi] Proxy auth mode, detecting user from /v1/account`);
|
||||||
|
const account = await this.get();
|
||||||
|
// Never store "*" (anonymous) as username
|
||||||
|
if (account.username && account.username !== "*") {
|
||||||
|
console.log(`[AccountApi] Proxy auth: storing session for ${account.username}`);
|
||||||
|
await session.store(account.username, ""); // Empty token for proxy auth
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
if (!session.exists()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.log(`[AccountApi] Syncing account`);
|
console.log(`[AccountApi] Syncing account`);
|
||||||
@@ -367,6 +379,11 @@ class AccountApi {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[AccountApi] Error fetching account`, e);
|
console.log(`[AccountApi] Error fetching account`, e);
|
||||||
if (e instanceof UnauthorizedError) {
|
if (e instanceof UnauthorizedError) {
|
||||||
|
// For proxy auth, hard refresh to get fresh auth from proxy
|
||||||
|
if (config.auth_mode === AuthMode.PROXY) {
|
||||||
|
window.location.reload();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
await session.resetAndRedirect(routes.login);
|
await session.resetAndRedirect(routes.login);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -431,5 +448,10 @@ export const Permission = {
|
|||||||
DENY_ALL: "deny-all",
|
DENY_ALL: "deny-all",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Maps to apiConfigResponse.AuthMode in server/types.go
|
||||||
|
export const AuthMode = {
|
||||||
|
PROXY: "proxy",
|
||||||
|
};
|
||||||
|
|
||||||
const accountApi = new AccountApi();
|
const accountApi = new AccountApi();
|
||||||
export default accountApi;
|
export default accountApi;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import Dexie from "dexie";
|
|||||||
/**
|
/**
|
||||||
* Manages the logged-in user's session and access token.
|
* Manages the logged-in user's session and access token.
|
||||||
* The session replica is stored in IndexedDB so that the service worker can access it.
|
* The session replica is stored in IndexedDB so that the service worker can access it.
|
||||||
|
*
|
||||||
|
* For proxy authentication (when config.auth_mode === "proxy"), the token will be empty
|
||||||
|
* since authentication is handled by the proxy. In this case, store(username, "") is called
|
||||||
|
* with an empty token, and exists() returns true based on the username alone.
|
||||||
*/
|
*/
|
||||||
class Session {
|
class Session {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -53,7 +57,7 @@ class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exists() {
|
exists() {
|
||||||
return this.username() && this.token();
|
return !!this.username();
|
||||||
}
|
}
|
||||||
|
|
||||||
username() {
|
username() {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import routes from "./routes";
|
|||||||
import db from "../app/db";
|
import db from "../app/db";
|
||||||
import { topicDisplayName } from "../app/utils";
|
import { topicDisplayName } from "../app/utils";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi, { AuthMode } from "../app/AccountApi";
|
||||||
import PopupMenu from "./PopupMenu";
|
import PopupMenu from "./PopupMenu";
|
||||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||||
import { useIsLaunchedPWA } from "./hooks";
|
import { useIsLaunchedPWA } from "./hooks";
|
||||||
@@ -139,6 +139,17 @@ const ProfileIcon = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
// For proxy auth, redirect to the logout URL if configured
|
||||||
|
if (config.auth_mode === AuthMode.PROXY) {
|
||||||
|
if (config.auth_logout_url) {
|
||||||
|
await db().delete();
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = config.auth_logout_url;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Standard logout
|
||||||
try {
|
try {
|
||||||
await accountApi.logout();
|
await accountApi.logout();
|
||||||
await db().delete();
|
await db().delete();
|
||||||
@@ -147,6 +158,9 @@ const ProfileIcon = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine if logout button should be shown (hide if proxy auth without logout URL)
|
||||||
|
const showLogout = config.auth_mode !== AuthMode.PROXY || config.auth_logout_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{session.exists() && (
|
{session.exists() && (
|
||||||
@@ -178,12 +192,14 @@ const ProfileIcon = () => {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
{t("action_bar_profile_settings")}
|
{t("action_bar_profile_settings")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleLogout}>
|
{showLogout && (
|
||||||
<ListItemIcon>
|
<MenuItem onClick={handleLogout}>
|
||||||
<Logout fontSize="small" />
|
<ListItemIcon>
|
||||||
</ListItemIcon>
|
<Logout fontSize="small" />
|
||||||
{t("action_bar_profile_logout")}
|
</ListItemIcon>
|
||||||
</MenuItem>
|
{t("action_bar_profile_logout")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</PopupMenu>
|
</PopupMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
|
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
|
||||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi, { AuthMode } from "../app/AccountApi";
|
||||||
import AvatarBox from "./AvatarBox";
|
import AvatarBox from "./AvatarBox";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
@@ -18,6 +18,13 @@ const Login = () => {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
// Redirect to app if using proxy authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.auth_mode === AuthMode.PROXY) {
|
||||||
|
window.location.href = routes.app;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const user = { username, password };
|
const user = { username, password };
|
||||||
|
|||||||
Reference in New Issue
Block a user