Compare commits
10 Commits
user-heade
...
scalar-api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3619c80544 | ||
|
|
27bce6f4d1 | ||
|
|
eb3549eedc | ||
|
|
cea5fececb | ||
|
|
f686b8c548 | ||
|
|
b26546b709 | ||
|
|
a06550a90f | ||
|
|
d2e0588037 | ||
|
|
854aa1f783 | ||
|
|
0ae2ca43bd |
@@ -37,7 +37,6 @@ 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,8 +95,6 @@ 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)"}),
|
||||||
@@ -208,8 +206,6 @@ 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")
|
||||||
@@ -317,8 +313,7 @@ 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 (/)")
|
||||||
@@ -343,21 +338,12 @@ 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 {
|
||||||
@@ -426,15 +412,6 @@ 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...)
|
||||||
|
|
||||||
@@ -460,8 +437,6 @@ 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
|
||||||
@@ -484,7 +459,13 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.TwilioAuthToken = twilioAuthToken
|
conf.TwilioAuthToken = twilioAuthToken
|
||||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||||
conf.TwilioVerifyService = twilioVerifyService
|
conf.TwilioVerifyService = twilioVerifyService
|
||||||
conf.TwilioCallFormat = twilioCallFormatTemplate
|
if twilioCallFormat != "" {
|
||||||
|
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
|
||||||
|
|||||||
192
docs/api/index.html
Normal file
192
docs/api/index.html
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<!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>
|
||||||
2370
docs/api/openapi.yaml
Normal file
2370
docs/api/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
35987
docs/api/scalar-standalone.js
Normal file
35987
docs/api/scalar-standalone.js
Normal file
File diff suppressed because one or more lines are too long
11911
docs/api/scalar-style.css
Normal file
11911
docs/api/scalar-style.css
Normal file
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,6 +91,7 @@ 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,8 +166,6 @@ 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
|
||||||
@@ -265,8 +263,6 @@ 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,10 +620,6 @@ 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,
|
||||||
@@ -639,8 +635,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,11 +667,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -2151,24 +2140,6 @@ 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,27 +124,6 @@
|
|||||||
# 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".
|
||||||
#
|
#
|
||||||
@@ -181,7 +160,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 STARTLS are supported.
|
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS 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
|
||||||
@@ -219,8 +198,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:
|
||||||
@@ -301,7 +280,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 uptream server requires authentication.
|
# if you exceed the upstream rate limits, or the upstream server requires authentication.
|
||||||
#
|
#
|
||||||
# upstream-base-url:
|
# upstream-base-url:
|
||||||
# upstream-access-token:
|
# upstream-access-token:
|
||||||
|
|||||||
@@ -2398,102 +2398,6 @@ 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,8 +483,6 @@ 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,6 +20,4 @@ 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,9 +1,7 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
|
||||||
import { registerRoute } from "workbox-routing";
|
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||||
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
|
import { NetworkFirst } 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";
|
||||||
@@ -339,42 +337,27 @@ clientsClaim();
|
|||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
if (!import.meta.env.DEV) {
|
if (!import.meta.env.DEV) {
|
||||||
// Use NetworkFirst for navigation requests. This ensures that auth proxies (like Authelia)
|
// we need the app_root setting, so we import the config.js file from the go server
|
||||||
// can intercept unauthenticated requests, while still providing offline fallback.
|
// this does NOT include the same base_url as the web app running in a window,
|
||||||
// The 3-second timeout means if the network is slow/unavailable, cached HTML is served.
|
// since we don't have access to `window` like in `src/app/config.js`
|
||||||
registerRoute(
|
self.importScripts("/config.js");
|
||||||
({ request }) => request.mode === "navigate",
|
|
||||||
new NetworkFirst({
|
|
||||||
cacheName: "html-cache",
|
|
||||||
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.
|
// this is the fallback single-page-app route, matching vite.config.js PWA config,
|
||||||
// Serves cached version immediately while fetching fresh version in the background.
|
// 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(
|
||||||
({ request }) =>
|
new NavigationRoute(createHandlerBoundToURL("/app.html"), {
|
||||||
request.destination === "script" ||
|
allowlist: [
|
||||||
request.destination === "style" ||
|
// the app root itself, could be /, or not
|
||||||
request.destination === "image" ||
|
new RegExp(`^${config.app_root}$`),
|
||||||
request.destination === "font",
|
|
||||||
new StaleWhileRevalidate({
|
|
||||||
cacheName: "assets-cache",
|
|
||||||
plugins: [
|
|
||||||
new CacheableResponsePlugin({ statuses: [200] }),
|
|
||||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle config.js with NetworkFirst. The manifest excludes it (see vite.config.js) since
|
// the manifest excludes config.js (see vite.config.js) since the dist-file differs from the
|
||||||
// the dist-file differs from the actual config served by the go server.
|
// actual config served by the go server. this adds it back with `NetworkFirst`, so that the
|
||||||
registerRoute(
|
// most recent config from the go server is cached, but the app still works if the network
|
||||||
({ url }) => url.pathname === "/config.js",
|
// is unavailable. this is important since there's no "refresh" button in the installed pwa
|
||||||
new NetworkFirst({
|
// to force a reload.
|
||||||
cacheName: "config-cache",
|
registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst());
|
||||||
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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";
|
||||||
@@ -342,18 +341,7 @@ class AccountApi {
|
|||||||
|
|
||||||
async sync() {
|
async sync() {
|
||||||
try {
|
try {
|
||||||
// For proxy auth, detect user from /v1/account if no session exists
|
if (!session.token()) {
|
||||||
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`);
|
||||||
@@ -379,11 +367,6 @@ 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;
|
||||||
@@ -448,10 +431,5 @@ 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,10 +3,6 @@ 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() {
|
||||||
@@ -57,7 +53,7 @@ class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exists() {
|
exists() {
|
||||||
return !!this.username();
|
return this.username() && this.token();
|
||||||
}
|
}
|
||||||
|
|
||||||
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, { AuthMode } from "../app/AccountApi";
|
import accountApi 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,17 +139,6 @@ 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();
|
||||||
@@ -158,9 +147,6 @@ 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() && (
|
||||||
@@ -192,14 +178,12 @@ const ProfileIcon = () => {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
{t("action_bar_profile_settings")}
|
{t("action_bar_profile_settings")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{showLogout && (
|
<MenuItem onClick={handleLogout}>
|
||||||
<MenuItem onClick={handleLogout}>
|
<ListItemIcon>
|
||||||
<ListItemIcon>
|
<Logout fontSize="small" />
|
||||||
<Logout fontSize="small" />
|
</ListItemIcon>
|
||||||
</ListItemIcon>
|
{t("action_bar_profile_logout")}
|
||||||
{t("action_bar_profile_logout")}
|
</MenuItem>
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</PopupMenu>
|
</PopupMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } 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, { AuthMode } from "../app/AccountApi";
|
import accountApi 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,13 +18,6 @@ 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