Merge branch 'main' into user-account

pull/637/head
binwiederhier 2023-02-22 19:22:47 -05:00
commit 4ab450309f
81 changed files with 4094 additions and 13687 deletions

View File

@ -8,7 +8,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v2

View File

@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v2

View File

@ -8,7 +8,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v2

View File

@ -13,11 +13,9 @@
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
It's also open source (as you can plainly see) if you want to run your own.
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do so since ntfy is open source.
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
<p>
<img src="web/public/static/img/screenshot-curl.png" height="180">
@ -115,6 +113,12 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
<a href="https://github.com/oakd"><img src="https://github.com/oakd.png" width="40px" /></a>
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:

View File

@ -87,6 +87,11 @@ func WithBasicAuth(user, pass string) PublishOption {
return WithHeader("Authorization", util.BasicAuth(user, pass))
}
// WithBearerAuth adds the Authorization header for Bearer auth to the request
func WithBearerAuth(token string) PublishOption {
return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token))
}
// WithNoCache instructs the server not to cache the message server-side
func WithNoCache() PublishOption {
return WithHeader("X-Cache", "no")

View File

@ -35,6 +35,7 @@ var flagsPublish = append(
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
@ -99,10 +100,18 @@ func execPublish(c *cli.Context) error {
file := c.String("file")
email := c.String("email")
user := c.String("user")
token := c.String("token")
noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase")
quiet := c.Bool("quiet")
pid := c.Int("wait-pid")
// Checks
if user != "" && token != "" {
return errors.New("cannot set both --user and --token")
}
// Do the things
topic, message, command, err := parseTopicMessageCommand(c)
if err != nil {
return err
@ -144,6 +153,9 @@ func execPublish(c *cli.Context) error {
if noFirebase {
options = append(options, client.WithNoFirebase())
}
if token != "" {
options = append(options, client.WithBearerAuth(token))
}
if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)

View File

@ -250,6 +250,7 @@ func execServe(c *cli.Context) error {
// Stripe things
if stripeSecretKey != "" {
stripe.EnableTelemetry = false // Whoa!
stripe.Key = stripeSecretKey
}

View File

@ -54,7 +54,9 @@ var cmdTier = &cli.Command{
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
&cli.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"},
},
Description: `Add a new tier to the ntfy user database.
@ -95,7 +97,8 @@ Examples:
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
&cli.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
},
Description: `Updates a tier to change the limits.
@ -109,7 +112,8 @@ Examples:
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier change \ # Update multiple limits and fields
--message-expiry-duration=24h \
--stripe-price-id=price_1234 \
--stripe-monthly-price-id=price_1234 \
--stripe-monthly-price-id=price_5678 \
pro
`,
},
@ -165,12 +169,20 @@ func execTierAdd(c *cli.Context) error {
return errors.New("tier code expected, type 'ntfy tier add --help' for help")
} else if !user.AllowedTier(code) {
return errors.New("tier code must consist only of numbers and letters")
} else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" {
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
} else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" {
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
if tier, _ := manager.Tier(code); tier != nil {
if c.Bool("ignore-exists") {
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
return nil
}
return fmt.Errorf("tier %s already exists", code)
}
name := c.String("name")
@ -201,7 +213,8 @@ func execTierAdd(c *cli.Context) error {
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
AttachmentBandwidthLimit: attachmentBandwidthLimit,
StripePriceID: c.String("stripe-price-id"),
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
}
if err := manager.AddTier(tier); err != nil {
return err
@ -268,8 +281,16 @@ func execTierChange(c *cli.Context) error {
return err
}
}
if c.IsSet("stripe-price-id") {
tier.StripePriceID = c.String("stripe-price-id")
if c.IsSet("stripe-monthly-price-id") {
tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id")
}
if c.IsSet("stripe-yearly-price-id") {
tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id")
}
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" {
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
} else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" {
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
}
if err := manager.UpdateTier(tier); err != nil {
return err
@ -314,9 +335,9 @@ func execTierList(c *cli.Context) error {
}
func printTier(c *cli.Context, tier *user.Tier) {
stripePriceID := tier.StripePriceID
if stripePriceID == "" {
stripePriceID = "(none)"
prices := "(none)"
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
}
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
@ -328,5 +349,5 @@ func printTier(c *cli.Context, tier *user.Tier) {
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
fmt.Fprintf(c.App.ErrWriter, "- Stripe price: %s\n", stripePriceID)
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
}

View File

@ -36,7 +36,8 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
"--attachment-expiry-duration=7h",
"--attachment-total-size-limit=10G",
"--attachment-bandwidth-limit=100G",
"--stripe-price-id=price_991",
"--stripe-monthly-price-id=price_991",
"--stripe-yearly-price-id=price_992",
"pro",
))
require.Contains(t, stderr.String(), "- Message limit: 999")
@ -46,7 +47,7 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h")
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
require.Contains(t, stderr.String(), "- Stripe price: price_991")
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))

View File

@ -46,6 +46,7 @@ var cmdUser = &cli.Command{
Action: execUserAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"},
},
Description: `Add a new user to the ntfy user database.
@ -186,6 +187,10 @@ func execUserAdd(c *cli.Context) error {
return err
}
if user, _ := manager.User(username); user != nil {
if c.Bool("ignore-exists") {
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
return nil
}
return fmt.Errorf("user %s already exists", username)
}
if password == "" {

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block announce %}
<style>
div[data-md-component="announce"] {
z-index: 10;
}
div[data-md-component="announce"] a {
color: white;
}
div[data-md-component="announce"] a:hover, div[data-md-component="announce"] a:focus {
transition: ease-in 150ms;
color: #ccc;
}
div[data-md-component="announce"] .md-banner__button {
color: #ccc;
}
div[data-md-component="announce"] .md-banner.hidden {
display: none;
}
div[data-md-component="announce"] .twemoji {
margin-top: 2px;
}
</style>
<button id="announce-bar-close" class="md-banner__button md-icon" aria-label="Don't show this again">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"></path>
</svg>
</button>
If you like ntfy, please consider sponsoring it via <a target="_blank" href="https://github.com/sponsors/binwiederhier"><strong>GitHub Sponsors</strong></a>
or <a target="_blank" href="https://en.liberapay.com/ntfy/"><strong>Liberapay</strong></a>
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 36 36" class="twemoji md-footer-custom-text">
<path fill="#DD2E44" d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>
<script>
announceBarKey = 'announce-bar-closed-sponsor';
document.getElementById('announce-bar-close').addEventListener('click', (e) => {
localStorage.setItem(announceBarKey, 'true');
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
});
if (localStorage.getItem(announceBarKey) === 'true') {
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
}
</script>
{% endblock %}

View File

@ -161,6 +161,7 @@ ntfy user add --role=admin phil # Add admin user phil
ntfy user del phil # Delete user phil
ntfy user change-pass phil # Change password for user phil
ntfy user change-role phil admin # Make user phil an admin
ntfy user change-tier phil pro # Change phil's tier to "pro"
```
### Access control list (ACL)
@ -222,6 +223,39 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
### Access tokens
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
want to use a dedicated token to publish from your backup host, and one from your home automation system.
!!! info
As of today, access tokens grant users **full access to the user account**. Aside from changing the password,
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
but not yet implemented.
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
automatically (or never expire). Each user can have up to 20 tokens (hardcoded).
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
```
ntfy token list # Shows list of tokens for all users
ntfy token list phil # Shows list of tokens for user phil
ntfy token add phil # Create token for user phil which never expires
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
ntfy token remove phil tk_th2sxr... # Delete token
```
**Creating an access token:**
```
$ ntfy token add --expires=30d --label="backups" phil
$ ntfy token list
user phil
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
```
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
### Example: Private instance
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
@ -754,6 +788,69 @@ Note that the self-hosted server literally sends the message `New message` for e
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
it'll show `New message` as a popup.
## Tiers
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way
to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)).
By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file.
Once a user is associated with a tier, some limits are overridden based on the tier.
The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers.
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
```
ntfy tier add pro # Add tier with code "pro", using the defaults
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier del starter # Delete an existing tier
ntfy user change-tier phil pro # Switch user "phil" to tier "pro"
```
**Creating a tier (full example):**
```
ntfy tier add \
--name="Pro" \
--message-limit=10000 \
--message-expiry-duration=24h \
--email-limit=50 \
--reservation-limit=10 \
--attachment-file-size-limit=100M \
--attachment-total-size-limit=1G \
--attachment-expiry-duration=12h \
--attachment-bandwidth-limit=5G \
--stripe-price-id=price_123456 \
pro
```
## Payments
ntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled,
users can register, login and switch plans in the web app. The web app will behave slightly differently if payments
are enabled (e.g. showing an upgrade banner, or "ntfy Pro" tags).
!!! info
The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use
cases.
To enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key`
config options:
* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values
enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).
* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.
Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).
In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)
for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points
to `https://ntfy.example.com/v1/account/billing/webhook`.
Here's an example:
``` yaml
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
```
## Rate limiting
!!! info
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
@ -788,7 +885,15 @@ request every 5s (defined by `visitor-request-limit-replenish`)
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
### Message limits
By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits).
For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests
to publish messages, then that is the daily message limit.
To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number
of messages a visitor can send in a day. This counter is reset every day at midnight (UTC).
### Attachment limits
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
per-visitor limits:
@ -962,18 +1067,57 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
maxretry = 10
```
## Debugging/tracing
## Logging & debugging
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded
by calling `kill -HUP $pid` or `systemctl reload ntfy`.
The following config options define the logging behavior:
* `log-format` defines the output format, can be `text` (default) or `json`
* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr.
* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`.
Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes.
* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful
for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
This is an array of strings in the format:
- `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace`
- `field -> level` to match any value, e.g. `time_taken_ms -> debug`
**Logging config (good for production use):**
``` yaml
log-level: info
log-format: json
log-file: /var/log/ntfy.log
```
**Temporary debugging:**
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
contents. The `TRACE` setting will also print the message contents.
to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message
contents. The `trace` setting will also print the message contents.
Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`),
a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what
they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy).
Here's an example that will output only `info` log events, except when they match either of the defined overrides:
``` yaml
log-level: info
log-level-overrides:
- "tag=manager -> trace"
- "visitor_ip=1.2.3.4 -> debug"
- "time_taken_ms -> debug"
```
!!! warning
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
you're going to run out of disk space pretty quickly.
The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a
performance penalty. Only use it for temporary debugging.
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
If successful, you'll see something like this:
You can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after
editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd),
or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this:
```
$ ntfy serve
@ -1029,14 +1173,15 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
| `enable-signup` | `NTFY_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
| `enable-reservations` | `NTFY_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
@ -1057,58 +1202,71 @@ CATEGORY:
DESCRIPTION:
Run the ntfy server and listen for incoming requests
The command will load the configuration from /etc/ntfy/server.yml. Config options can
be overridden using the command line options.
Examples:
ntfy serve # Starts server in the foreground (on port 80)
ntfy serve --listen-http :8080 # Starts server with alternate port
OPTIONS:
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
--enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
--enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
--help, -h show help (default: false)
```

View File

@ -327,7 +327,76 @@ To build your own version with Firebase, you must:
```
## iOS app
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are
strictly based off of my development on this app. There may be other versions of macOS / XCode that work.
### Requirements
1. macOS Monterey or later
1. XCode 13.2+
1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)
1. Firebase account
1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)
### Apple setup
!!! info
I haven't had time to move the build instructions here. Please check out the repository instead.
Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
for these changes to take effect in the iOS app.
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
1. Select "Apple Push Notifications service (APNs)"
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer.
!!! warning
If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.
If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered
instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application
sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,
days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly
recommended.
### Firebase setup
1. If you haven't already, create a Google / Firebase account
1. Visit the [Firebase console](https://console.firebase.google.com)
1. Create a new Firebase project:
1. Enter a project name
1. Disable Google Analytics (currently iOS app does not support analytics)
1. On the "Project settings" page, add an iOS app
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
1. Register the app
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
1. Generate a new service account private key for the ntfy server
1. Go to "Project settings" > "Service accounts"
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
### ntfy server
Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these
steps:
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
1. Install go: `brew install go`
1. In the ntfy repository, run `make cli-darwin-server`.
### XCode setup
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the
`firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging
1. Similarly, install the SQLite.swift package dependency in XCode
1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators
### PLIST config
To have instant notifications/better notification delivery when using firebase, you will need to add the
`GoogleService-Info.plist` file to your project. Here's how to do that:
1. In XCode, find the NTFY app target. **Not** the NSE app target.
1. Find the Asset/ folder in the project navigator
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
After that, you should be all set!

View File

@ -26,37 +26,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_x86_64.tar.gz
tar zxvf ntfy_2.0.1_linux_x86_64.tar.gz
sudo cp -a ntfy_2.0.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_armv6.tar.gz
tar zxvf ntfy_2.0.1_linux_armv6.tar.gz
sudo cp -a ntfy_2.0.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.1_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_armv7.tar.gz
tar zxvf ntfy_2.0.1_linux_armv7.tar.gz
sudo cp -a ntfy_2.0.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.1_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_arm64.tar.gz
tar zxvf ntfy_2.0.1_linux_arm64.tar.gz
sudo cp -a ntfy_2.0.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.1_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@ -106,7 +106,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -114,7 +114,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -122,7 +122,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -130,7 +130,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -140,28 +140,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_macOS_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_macOS_all.tar.gz > ntfy_2.0.1_macOS_all.tar.gz
tar zxvf ntfy_2.0.1_macOS_all.tar.gz
sudo cp -a ntfy_2.0.1_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.0.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@ -212,7 +212,7 @@ ntfy --help
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_windows_x86_64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.0.1/ntfy_2.0.1_windows_x86_64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).

View File

@ -35,6 +35,8 @@ and uptime of third party servers, so use of each server is **at your own discre
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
- [Scrt.link](https://scrt.link/) - Share a secret
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
@ -72,6 +74,7 @@ and uptime of third party servers, so use of each server is **at your own discre
## Projects + scripts
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
@ -110,9 +113,16 @@ and uptime of third party servers, so use of each server is **at your own discre
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog)
## Blog + forum posts
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023
- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023
- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022

View File

@ -2582,6 +2582,11 @@ format is:
ntfy-$topic@ntfy.sh
```
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work without providing an authorized access token. That will change the format of the e-mail's recipient address to
```
ntfy-$topic+$token@ntfy.sh
```
As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority,
delay and other features are not supported (yet). Here's an example that will publish a message with the
title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)):
@ -2591,23 +2596,22 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
<figcaption>Publishing a message via e-mail</figcaption>
</figure>
## Advanced features
### Authentication
## Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
To publish/subscribe to protected topics, you can:
* Use [basic auth](#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
* or use the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
* Use [access tokens](#bearer-auth) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`
* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
!!! warning
Base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server,
**be sure to use HTTPS to avoid eavesdropping** and exposing your password.
When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your
self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password.
#### Basic auth
Here's an example using [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication), with a user `testuser`
and password `fakepassword`:
### Username & password
The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication).
Here's an example with a user `testuser` and password `fakepassword`:
=== "Command line (curl)"
```
@ -2701,7 +2705,172 @@ The following command will generate the appropriate value for you on *nix system
echo "Basic $(echo -n 'testuser:fakepassword' | base64)"
```
#### Query param
### Access tokens
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
want to use a dedicated token to publish from your backup host, and one from your home automation system.
You can create access tokens using the `ntfy token` command, or in the web app in the "Account" section (when logged in).
See [access tokens](config.md#access-tokens) for details.
Once an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or
subscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/),
with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:
=== "Command line (curl)"
```
curl \
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
'content' => 'Look ma, with auth'
]
]));
```
Alternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the
access token. When sending an empty username, the basic auth password is treated by the ntfy server as an
access token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`:
=== "Command line (curl)"
```
curl \
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy',
'content' => 'Look ma, with auth'
]
]));
```
### Query param
Here's an example using the `auth` query parameter:
=== "Command line (curl)"
@ -2766,7 +2935,7 @@ Here's an example using the `auth` query parameter:
]));
```
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see anove) using
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using
**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully
explains it better:
@ -2786,6 +2955,8 @@ The following command will generate the appropriate value for you on *nix system
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
```
## Advanced features
### Message caching
!!! info
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
@ -2984,9 +3155,6 @@ that you can use to try out what [authentication and access control](#authentica
|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------|
| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such |
| [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage |
| [mytopic-rw](https://ntfy.sh/mytopic-rw) | `testuser` (password: `testuser`) | Read-write for `testuser`, no access for anyone else | Test topic |
| [mytopic-ro](https://ntfy.sh/mytopic-ro) | `testuser` (password: `testuser`) | Read-only for `testuser`, no access for anyone else | Test topic |
| [mytopic-wo](https://ntfy.sh/mytopic-wo) | `testuser` (password: `testuser`) | Write-only for `testuser`, no access for anyone else | Test topic |
## Limitations
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings

View File

@ -2,16 +2,109 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
## ntfy server v1.31.0 (UNRELEASED)
Released XXXX
This is the biggest ntfy server release I've ever done. This release adds the ability to sign-up for accounts, log-in
via the web app, synchronize accounts between devices (web only for now), introduces user access tokens, user tiers,
and Stripe payments integration to support paid tiers (don't worry, [everything will stay open source](https://ntfy.sh/docs/faq/)).
## ntfy server v2.0.2 (UNRELEASED)
**Features:**
* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore))
* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts))
* Add support for annual billing intervals (no ticket)
**Bug fixes + maintenance:**
* Web: Do not disable "Reserve topic" checkbox for admins (no ticket, thanks to @xenrox for reporting)
**Additional languages:**
* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/))
## ntfy server v2.0.1
Released February 17, 2023
This is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set.
**Bug fixes + maintenance:**
* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))
* Ensure that calls to standard logger `log.Println` also output JSON (no ticket)
## ntfy server v2.0.0
Released February 16, 2023
This is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features.
**Brand-new features:**
* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to
the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as
opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS
app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and
`enable-login`.
<div id="account-screenshots" class="screenshots">
<a href="../../static/img/web-signup.png"><img src="../../static/img/web-signup.png"/></a>
<a href="../../static/img/web-account.png"><img src="../../static/img/web-account.png"/></a>
</div>
* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**.
Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe
to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as
`deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you
can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner).
Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and
only if the user has a [tier](config.md#tiers) that supports reservations.
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>
* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful
to avoid having to paste your password to various applications or scripts. For instance, you may want to use a
dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured
in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens),
and [publishing using tokens](publish.md#access-tokens).
<div id="token-screenshots" class="screenshots">
<a href="../../static/img/web-token-create.png"><img src="../../static/img/web-token-create.png"/></a>
<a href="../../static/img/web-token-list.png"><img src="../../static/img/web-token-list.png"/></a>
</div>
* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and
troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields
that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields
match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level.
See [logging & debugging](config.md#logging-debugging).
* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have
a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another
tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below).
Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers).
* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be
able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations.
Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments)
for details.
**ntfy is forever open source!**
Yes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything
will remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
and [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep
going. It'll only make ntfy better.
**Other tickets:**
* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522))
* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)
**Special thanks:**
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot.
## ntfy server v1.31.0
Released February 14, 2023
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
breaking-change upgrade, which required some work to get working again.
**Features:**
* ⭐ User account signup, login, topic reservations, access tokens, tiers etc. ⭐ ([#522](https://github.com/binwiederhier/ntfy/issues/522))
* Preliminary `/v1/health` API endpoint for service monitoring (no ticket)
* Add basic health check to `Dockerfile` ([#555](https://github.com/binwiederhier/ntfy/pull/555), thanks to [@bt90](https://github.com/bt90))
@ -19,7 +112,7 @@ and Stripe payments integration to support paid tiers (don't worry, [everything
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
**Documentation:**
@ -27,16 +120,12 @@ and Stripe payments integration to support paid tiers (don't worry, [everything
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
**Additional languages:**
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
**Special thanks:**
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, and @xenrox.
## ntfy server v1.30.1
Released December 23, 2022 🎅

View File

@ -2,16 +2,13 @@
--md-primary-fg-color: #338574;
--md-primary-fg-color--light: #338574;
--md-primary-fg-color--dark: #338574;
--md-footer-bg-color: #353744;
}
.md-header__button.md-logo :is(img, svg) {
width: unset !important;
}
header {
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
}
.md-header__topic:first-child {
font-weight: 400;
}
@ -34,12 +31,30 @@ figure img, figure video {
border-radius: 7px;
}
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video {
header {
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
}
body[data-md-color-scheme="default"] header {
filter: drop-shadow(0 5px 10px #ccc);
}
body[data-md-color-scheme="slate"] header {
filter: drop-shadow(0 5px 10px #333);
}
body[data-md-color-scheme="default"] figure img,
body[data-md-color-scheme="default"] figure video,
body[data-md-color-scheme="default"] .screenshots img,
body[data-md-color-scheme="default"] .screenshots video {
filter: drop-shadow(3px 3px 3px #ccc);
}
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
filter: drop-shadow(3px 3px 3px #1a1313);
body[data-md-color-scheme="slate"] figure img,
body[data-md-color-scheme="slate"] figure video,
body[data-md-color-scheme="slate"] .screenshots img,
body[data-md-color-scheme="slate"] .screenshots video {
filter: drop-shadow(3px 3px 3px #353744);
}
figure video {

BIN
docs/static/img/web-account.png vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
docs/static/img/web-signup.png vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@ -319,7 +319,7 @@ format of the message. It's very straight forward:
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted |
| `expires` | () | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |

View File

@ -18,3 +18,10 @@ is to pin the tab so that it's always open, but sort of out of the way:
![pinned](../static/img/web-pin.png){ width=500 }
<figcaption>Pin web app to move it out of the way</figcaption>
</figure>
If topic reservations are enabled, you can claim ownership over topics and define access to it:
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>

36
go.mod
View File

@ -4,22 +4,22 @@ go 1.18
require (
cloud.google.com/go/firestore v1.9.0 // indirect
cloud.google.com/go/storage v1.28.1 // indirect
cloud.google.com/go/storage v1.29.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/emersion/go-smtp v0.16.0
github.com/gabriel-vasile/mimetype v1.4.1
github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.16
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.23.7
golang.org/x/crypto v0.4.0
golang.org/x/oauth2 v0.3.0 // indirect
github.com/urfave/cli/v2 v2.24.4
golang.org/x/crypto v0.6.0
golang.org/x/oauth2 v0.5.0 // indirect
golang.org/x/sync v0.1.0
golang.org/x/term v0.3.0
golang.org/x/term v0.5.0
golang.org/x/time v0.3.0
google.golang.org/api v0.105.0
google.golang.org/api v0.110.0
gopkg.in/yaml.v2 v2.4.0
)
@ -27,15 +27,15 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.10.0
github.com/stripe/stripe-go/v74 v74.5.0
github.com/stripe/stripe-go/v74 v74.8.0
)
require (
cloud.google.com/go v0.107.0 // indirect
cloud.google.com/go/compute v1.14.0 // indirect
cloud.google.com/go v0.110.0 // indirect
cloud.google.com/go/compute v1.18.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v0.9.0 // indirect
cloud.google.com/go/longrunning v0.3.0 // indirect
cloud.google.com/go/iam v0.12.0 // indirect
cloud.google.com/go/longrunning v0.4.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@ -45,21 +45,21 @@ require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.4.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 // indirect
google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

74
go.sum
View File

@ -1,18 +1,18 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww=
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs=
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE=
cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@ -71,12 +71,12 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@ -101,18 +101,18 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/stripe/stripe-go/v74 v74.8.0 h1:0+3EfQSBhMg8SQ1+w+AP6Gxyko2crWbUG2uXbzYs8SU=
github.com/stripe/stripe-go/v74 v74.8.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -127,11 +127,11 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -144,17 +144,17 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -165,8 +165,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@ -176,15 +176,15 @@ google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4Ho
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY=
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 h1:EfLuoKW5WfkgVdDy7dTK8qSbH37AX5mj/MFh+bGPz14=
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@ -11,10 +11,11 @@ import (
)
const (
tagField = "tag"
errorField = "error"
timeTakenField = "time_taken_ms"
exitCodeField = "exit_code"
fieldTag = "tag"
fieldError = "error"
fieldTimeTaken = "time_taken_ms"
fieldExitCode = "exit_code"
tagStdLog = "stdlog"
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
)
@ -40,7 +41,7 @@ func newEvent() *Event {
// Fatal logs the event as FATAL, and exits the program with exit code 1
func (e *Event) Fatal(message string, v ...any) {
e.Field(exitCodeField, 1).maybeLog(FatalLevel, message, v...)
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...)
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
os.Exit(1)
}
@ -72,7 +73,7 @@ func (e *Event) Trace(message string, v ...any) {
// Tag adds a "tag" field to the log event
func (e *Event) Tag(tag string) *Event {
return e.Field(tagField, tag)
return e.Field(fieldTag, tag)
}
// Time sets the time field
@ -85,7 +86,7 @@ func (e *Event) Time(t time.Time) *Event {
func (e *Event) Timing(f func()) *Event {
start := time.Now()
f()
return e.Field(timeTakenField, time.Since(start).Milliseconds())
return e.Field(fieldTimeTaken, time.Since(start).Milliseconds())
}
// Err adds an "error" field to the log event
@ -95,7 +96,7 @@ func (e *Event) Err(err error) *Event {
} else if c, ok := err.(Contexter); ok {
return e.With(c)
}
return e.Field(errorField, err.Error())
return e.Field(fieldError, err.Error())
}
// Field adds a custom field and value to the log event
@ -128,17 +129,17 @@ func (e *Event) With(contexts ...Contexter) *Event {
return e
}
// maybeLog logs the event to the defined output. The event is only logged, if
// either the global log level is >= l, or if the log level in one of the overrides matches
// Render returns the rendered log event as a string, or an empty string. The event is only rendered,
// if either the global log level is >= l, or if the log level in one of the overrides matches
// the level.
//
// If no overrides are defined (default), the Contexter array is not applied unless the event
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
// to determine if they match. This is super complicated, but required for efficiency.
func (e *Event) maybeLog(l Level, message string, v ...any) {
func (e *Event) Render(l Level, message string, v ...any) string {
appliedContexters := e.maybeApplyContexters()
if !e.shouldLog(l) {
return
return ""
}
e.Message = fmt.Sprintf(message, v...)
e.Level = l
@ -147,9 +148,15 @@ func (e *Event) maybeLog(l Level, message string, v ...any) {
e.applyContexters()
}
if CurrentFormat() == JSONFormat {
log.Println(e.JSON())
} else {
log.Println(e.String())
return e.JSON()
}
return e.String()
}
// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string
func (e *Event) maybeLog(l Level, message string, v ...any) {
if m := e.Render(l, message, v...); m != "" {
log.Println(m)
}
}

View File

@ -4,6 +4,7 @@ import (
"io"
"log"
"os"
"strings"
"sync"
"time"
)
@ -12,7 +13,7 @@ import (
var (
DefaultLevel = InfoLevel
DefaultFormat = TextFormat
DefaultOutput = os.Stderr
DefaultOutput = &peekLogWriter{os.Stderr}
)
var (
@ -20,9 +21,18 @@ var (
format = DefaultFormat
overrides = make(map[string]*levelOverride)
output io.Writer = DefaultOutput
filename = ""
mu = &sync.RWMutex{}
)
// init sets the default log output (including log.SetOutput)
//
// This has to be explicitly called, because DefaultOutput is a peekLogWriter,
// which wraps os.Stderr.
func init() {
SetOutput(DefaultOutput)
}
// Fatal prints the given message, and exits the program
func Fatal(message string, v ...any) {
newEvent().Fatal(message, v...)
@ -132,28 +142,27 @@ func SetFormat(newFormat Format) {
func SetOutput(w io.Writer) {
mu.Lock()
defer mu.Unlock()
log.SetOutput(w)
output = w
output = &peekLogWriter{w}
if f, ok := w.(*os.File); ok {
filename = f.Name()
} else {
filename = ""
}
log.SetOutput(output)
}
// File returns the log file, if any, or an empty string otherwise
func File() string {
mu.RLock()
defer mu.RUnlock()
if f, ok := output.(*os.File); ok {
return f.Name()
}
return ""
return filename
}
// IsFile returns true if the output is a non-default file
func IsFile() bool {
mu.RLock()
defer mu.RUnlock()
if _, ok := output.(*os.File); ok && output != DefaultOutput {
return true
}
return false
return filename != ""
}
// DisableDates disables the date/time prefix
@ -175,3 +184,20 @@ func IsTrace() bool {
func IsDebug() bool {
return Loggable(DebugLevel)
}
// peekLogWriter is an io.Writer which will peek at the rendered log event,
// and ensure that the rendered output is valid JSON. This is a hack!
type peekLogWriter struct {
w io.Writer
}
func (w *peekLogWriter) Write(p []byte) (n int, err error) {
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
return w.w.Write(p)
}
m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p)))
if m == "" {
return 0, nil
}
return w.w.Write([]byte(m + "\n"))
}

View File

@ -4,7 +4,10 @@ import (
"bytes"
"encoding/json"
"github.com/stretchr/testify/require"
"io"
"log"
"os"
"path/filepath"
"testing"
"time"
)
@ -170,6 +173,51 @@ func TestLog_LevelOverrideAny(t *testing.T) {
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
`
require.Equal(t, expected, out.String())
require.False(t, IsFile())
require.Equal(t, "", File())
}
func TestLog_UsingStdLogger_JSON(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
log.Println("Some other library is using the standard Go logger")
require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n")
}
func TestLog_UsingStdLogger_Text(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
log.Println("Some other library is using the standard Go logger")
require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n")
require.NotContains(t, out.String(), `{`)
}
func TestLog_File(t *testing.T) {
t.Cleanup(resetState)
logfile := filepath.Join(t.TempDir(), "ntfy.log")
f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600)
require.Nil(t, err)
SetOutput(f)
SetFormat(JSONFormat)
require.True(t, IsFile())
require.Equal(t, logfile, File())
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged")
require.Nil(t, f.Close())
f, err = os.Open(logfile)
require.Nil(t, err)
contents, err := io.ReadAll(f)
require.Nil(t, err)
require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents))
}
type fakeError struct {

View File

@ -10,6 +10,7 @@ edit_uri: blob/main/docs/
theme:
name: material
language: en
custom_dir: docs/_overrides
logo: static/img/ntfy.png
favicon: static/img/favicon.png
include_search_page: false
@ -76,7 +77,7 @@ nav:
- "Sending messages": publish.md
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web UI": subscribe/web.md
- "From the Web app": subscribe/web.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":

View File

@ -61,7 +61,7 @@ var (
// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
// extended using the server.yml config. If updated, also update in Android and web app.
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login"}
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"}
)
// Config is the main config struct for the application. Use New to instantiate a default config struct.

View File

@ -11,19 +11,38 @@ import (
"unicode/utf8"
)
// Log tags
const (
tagStartup = "startup"
tagHTTP = "http"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagFileCache = "file_cache"
tagMessageCache = "message_cache"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
)
// logr creates a new log event with HTTP request fields
func logr(r *http.Request) *log.Event {
return log.Fields(httpContext(r))
return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten
}
// logr creates a new log event with visitor fields
// logv creates a new log event with visitor fields
func logv(v *visitor) *log.Event {
return log.With(v)
}
// logr creates a new log event with HTTP request and visitor fields
// logvr creates a new log event with HTTP request and visitor fields
func logvr(v *visitor, r *http.Request) *log.Event {
return logv(v).Fields(httpContext(r))
return logr(r).With(v)
}
// logvrm creates a new log event with HTTP request, visitor fields and message fields
@ -37,13 +56,12 @@ func logvm(v *visitor, m *message) *log.Event {
}
// logem creates a new log event with email fields
func logem(state *smtp.ConnectionState) *log.Event {
return log.
Tag(tagSMTP).
Fields(log.Context{
"smtp_hostname": state.Hostname,
"smtp_remote_addr": state.RemoteAddr.String(),
})
func logem(smtpConn *smtp.Conn) *log.Event {
ev := log.Tag(tagSMTP).Field("smtp_hostname", smtpConn.Hostname())
if smtpConn.Conn() != nil {
ev.Field("smtp_remote_addr", smtpConn.Conn().RemoteAddr().String())
}
return ev
}
func httpContext(r *http.Request) log.Context {

View File

@ -536,7 +536,7 @@ func (c *messageCache) ExpireMessages(topics ...string) error {
}
defer tx.Rollback()
for _, t := range topics {
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix(), t); err != nil {
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil {
return err
}
}

View File

@ -33,16 +33,6 @@ import (
"heckel.io/ntfy/util"
)
/*
- MEDIUM fail2ban to work with ntfy log not nginx log
- HIGH Docs
- tiers
- api
- tokens
*/
// Server is the main server, providing the UI and API for ntfy
type Server struct {
config *Config
@ -56,11 +46,11 @@ type Server struct {
visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient
messages int64
userManager *user.Manager // Might be nil!
messageCache *messageCache // Database that stores the messages
fileCache *fileCache // File system based cache that stores attachments
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]string] // Stripe price ID -> formatted price
userManager *user.Manager // Might be nil!
messageCache *messageCache // Database that stores the messages
fileCache *fileCache // File system based cache that stores attachments
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
closeChan chan bool
mu sync.Mutex
}
@ -134,24 +124,6 @@ const (
wsPongWait = 15 * time.Second
)
// Log tags
const (
tagStartup = "startup"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagFileCache = "file_cache"
tagMessageCache = "message_cache"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
)
// New instantiates a new Server. It creates the cache and adds a Firebase
// subscriber (if configured).
func New(conf *Config) (*Server, error) {
@ -327,11 +299,11 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.handleError(w, r, v, err)
return
}
if log.IsTrace() {
logvr(v, r).Field("http_request", renderHTTPRequest(r)).Trace("HTTP request started")
} else if log.IsDebug() {
logvr(v, r).Debug("HTTP request started")
ev := logvr(v, r)
if ev.IsTrace() {
ev.Field("http_request", renderHTTPRequest(r)).Trace("HTTP request started")
} else if logvr(v, r).IsDebug() {
ev.Debug("HTTP request started")
}
logvr(v, r).
Timing(func() {
@ -344,8 +316,12 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, err error) {
httpErr, ok := err.(*errHTTP)
if !ok {
httpErr = errHTTPInternalError
}
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains([]int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized}, httpErr.HTTPCode)
if websocket.IsWebSocketUpgrade(r) {
isNormalError := strings.Contains(err.Error(), "i/o timeout")
if isNormalError {
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error())
} else {
@ -354,22 +330,15 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
return // Do not attempt to write to upgraded connection
}
if matrixErr, ok := err.(*errMatrix); ok {
writeMatrixError(w, r, v, matrixErr)
if err := writeMatrixError(w, r, v, matrixErr); err != nil {
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Writing Matrix error failed")
}
return
}
httpErr, ok := err.(*errHTTP)
if !ok {
httpErr = errHTTPInternalError
}
isNormalError := httpErr.HTTPCode == http.StatusNotFound || httpErr.HTTPCode == http.StatusBadRequest || httpErr.HTTPCode == http.StatusTooManyRequests
if isNormalError {
logvr(v, r).
Err(httpErr).
Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
logvr(v, r).Err(err).Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
} else {
logvr(v, r).
Err(httpErr).
Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
logvr(v, r).Err(err).Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@ -629,7 +598,9 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
}
m.Sender = v.IP()
m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(vRate.Limits().MessageExpiryDuration).Unix()
if cache {
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
}
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
return nil, err
}
@ -637,21 +608,18 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
m.Message = emptyMessageBody
}
delayed := m.Time > time.Now().Unix()
logvrm(vRate, r, m).
ev := logvrm(vRate, r, m).
Tag(tagPublish).
Fields(log.Context{
"message_delayed": delayed,
"message_firebase": firebase,
"message_unifiedpush": unifiedpush,
"message_email": email,
"message_subscriber_rate_limited": vRate != v,
}).
Debug("Received message")
if log.IsTrace() {
logvrm(vRate, r, m).
Tag(tagPublish).
Field("message_body", util.MaybeMarshalJSON(m)).
Trace("Message body")
"message_delayed": delayed,
"message_firebase": firebase,
"message_unifiedpush": unifiedpush,
"message_email": email,
})
if ev.IsTrace() {
ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message")
} else if ev.IsDebug() {
ev.Debug("Received message")
}
if !delayed {
if err := t.Publish(v, m); err != nil {
@ -1176,7 +1144,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return err
}
err = g.Wait()
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) {
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace("WebSocket connection closed")
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
}
@ -1309,161 +1277,6 @@ func (s *Server) topicFromID(id string) (*topic, error) {
return topics[0], nil
}
func (s *Server) execManager() {
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
// there is no mutex for the entire function.
// Expire visitors from rate visitors map
staleVisitors := 0
log.
Tag(tagManager).
Timing(func() {
s.mu.Lock()
defer s.mu.Unlock()
for ip, v := range s.visitors {
if v.Stale() {
log.Tag(tagManager).With(v).Trace("Deleting stale visitor")
delete(s.visitors, ip)
staleVisitors++
}
}
}).
Field("stale_visitors", staleVisitors).
Debug("Deleted %d stale visitor(s)", staleVisitors)
// Delete expired user tokens and users
if s.userManager != nil {
log.
Tag(tagManager).
Timing(func() {
if err := s.userManager.RemoveExpiredTokens(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error expiring user tokens")
}
if err := s.userManager.RemoveDeletedUsers(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
}
}).
Debug("Removed expired tokens and users")
}
// Delete expired attachments
if s.fileCache != nil {
log.
Tag(tagManager).
Timing(func() {
ids, err := s.messageCache.AttachmentsExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
} else if len(ids) > 0 {
if log.Tag(tagManager).IsDebug() {
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.fileCache.Remove(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
}
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired attachments to delete")
}
}).
Debug("Deleted expired attachments")
}
// Prune messages
log.
Tag(tagManager).
Timing(func() {
expiredMessageIDs, err := s.messageCache.MessagesExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
} else if len(expiredMessageIDs) > 0 {
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
}
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired messages to delete")
}
}).
Debug("Pruned messages")
// Message count per topic
var messagesCached int
messageCounts, err := s.messageCache.MessageCounts()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Cannot get message counts")
messageCounts = make(map[string]int) // Empty, so we can continue
}
for _, count := range messageCounts {
messagesCached += count
}
// Remove subscriptions without subscribers
var emptyTopics, subscribers int
log.
Tag(tagManager).
Timing(func() {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.topics {
subs := t.SubscribersCount()
ev := log.Tag(tagManager)
if ev.IsTrace() {
expiryMessage := ""
if subs == 0 {
expiryTime := time.Until(t.vRateExpires)
expiryMessage = ", expires in " + expiryTime.String()
}
ev.Trace("- topic %s: %d subscribers%s", t.ID, subs, expiryMessage)
}
msgs, exists := messageCounts[t.ID]
if t.Stale() && (!exists || msgs == 0) {
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
emptyTopics++
delete(s.topics, t.ID)
continue
}
subscribers += subs
}
}).
Debug("Removed %d empty topic(s)", emptyTopics)
// Mail stats
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
if s.smtpServerBackend != nil {
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
}
var sentMailTotal, sentMailSuccess, sentMailFailure int64
if s.smtpSender != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
}
// Print stats
s.mu.Lock()
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
s.mu.Unlock()
log.
Tag(tagManager).
Fields(log.Context{
"messages_published": messagesCount,
"messages_cached": messagesCached,
"topics_active": topicsCount,
"subscribers": subscribers,
"visitors": visitorsCount,
"emails_received": receivedMailTotal,
"emails_received_success": receivedMailSuccess,
"emails_received_failure": receivedMailFailure,
"emails_sent": sentMailTotal,
"emails_sent_success": sentMailSuccess,
"emails_sent_failure": sentMailFailure,
}).
Info("Server stats")
}
func (s *Server) runSMTPServer() error {
s.smtpServerBackend = newMailBackend(s.config, s.handle)
s.smtpServer = smtp.NewServer(s.smtpServerBackend)

View File

@ -246,7 +246,7 @@
# Logging options
#
# By default, ntfy logs to the console (stderr), with a "info" log level, and in a human-readable text format.
# By default, ntfy logs to the console (stderr), with an "info" log level, and in a human-readable text format.
# ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
# log level overrides for easier debugging. Some options (log-level and log-level-overrides) can be hot reloaded
# by calling "kill -HUP $pid" or "systemctl reload ntfy".

View File

@ -100,6 +100,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
Customer: true,
Subscription: u.Billing.StripeSubscriptionID != "",
Status: string(u.Billing.StripeSubscriptionStatus),
Interval: string(u.Billing.StripeSubscriptionInterval),
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
}
@ -479,6 +480,7 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
if err := s.messageCache.ExpireMessages(topic); err != nil {
return err
}
s.pruneMessages()
}
return s.writeJSON(w, newSuccessResponse())
}
@ -505,6 +507,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
if err := s.messageCache.ExpireMessages(topics...); err != nil {
return err
}
go s.pruneMessages()
return nil
}

View File

@ -669,8 +669,8 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.Equal(t, 200, rr.Code)
// Verify that messages and attachments were deleted
// This does not explicitly call the manager!
time.Sleep(time.Second)
s.execManager()
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
require.Nil(t, err)
@ -804,10 +804,27 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(1), account.Stats.Messages) // Is not reset!
// Publish another message
rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Verify that message stats were persisted
time.Sleep(300 * time.Millisecond)
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(0), u.Stats.Messages) // v.EnqueueUserStats had run!
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
// Stats keep counting
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
}

View File

@ -8,7 +8,6 @@ import (
"firebase.google.com/go/v4/messaging"
"fmt"
"google.golang.org/api/option"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"strings"
@ -46,16 +45,15 @@ func (c *firebaseClient) Send(v *visitor, m *message) error {
if err != nil {
return err
}
if log.Tag(tagFirebase).IsTrace() {
logvm(v, m).
Tag(tagFirebase).
Field("firebase_message", util.MaybeMarshalJSON(fbm)).
Trace("Firebase message")
ev := logvm(v, m).Tag(tagFirebase)
if ev.IsTrace() {
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
}
err = c.sender.Send(fbm)
if err == errFirebaseQuotaExceeded {
logvm(v, m).
Tag(tagFirebase).
Err(err).
Warn("Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor")
v.FirebaseTemporarilyDeny()
}

View File

@ -0,0 +1,175 @@
package server
import (
"heckel.io/ntfy/log"
"strings"
"time"
)
func (s *Server) execManager() {
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
// there is no mutex for the entire function.
// Prune all the things
s.pruneVisitors()
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
// Message count per topic
var messagesCached int
messageCounts, err := s.messageCache.MessageCounts()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Cannot get message counts")
messageCounts = make(map[string]int) // Empty, so we can continue
}
for _, count := range messageCounts {
messagesCached += count
}
// Remove subscriptions without subscribers
var emptyTopics, subscribers int
log.
Tag(tagManager).
Timing(func() {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.topics {
subs := t.SubscribersCount()
ev := log.Tag(tagManager)
if ev.IsTrace() {
expiryMessage := ""
if subs == 0 {
expiryTime := time.Until(t.vRateExpires)
expiryMessage = ", expires in " + expiryTime.String()
}
ev.Trace("- topic %s: %d subscribers%s", t.ID, subs, expiryMessage)
}
msgs, exists := messageCounts[t.ID]
if t.Stale() && (!exists || msgs == 0) {
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
emptyTopics++
delete(s.topics, t.ID)
continue
}
subscribers += subs
}
}).
Debug("Removed %d empty topic(s)", emptyTopics)
// Mail stats
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
if s.smtpServerBackend != nil {
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
}
var sentMailTotal, sentMailSuccess, sentMailFailure int64
if s.smtpSender != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
}
// Print stats
s.mu.Lock()
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
s.mu.Unlock()
log.
Tag(tagManager).
Fields(log.Context{
"messages_published": messagesCount,
"messages_cached": messagesCached,
"topics_active": topicsCount,
"subscribers": subscribers,
"visitors": visitorsCount,
"emails_received": receivedMailTotal,
"emails_received_success": receivedMailSuccess,
"emails_received_failure": receivedMailFailure,
"emails_sent": sentMailTotal,
"emails_sent_success": sentMailSuccess,
"emails_sent_failure": sentMailFailure,
}).
Info("Server stats")
}
func (s *Server) pruneVisitors() {
staleVisitors := 0
log.
Tag(tagManager).
Timing(func() {
s.mu.Lock()
defer s.mu.Unlock()
for ip, v := range s.visitors {
if v.Stale() {
log.Tag(tagManager).With(v).Trace("Deleting stale visitor")
delete(s.visitors, ip)
staleVisitors++
}
}
}).
Field("stale_visitors", staleVisitors).
Debug("Deleted %d stale visitor(s)", staleVisitors)
}
func (s *Server) pruneTokens() {
if s.userManager != nil {
log.
Tag(tagManager).
Timing(func() {
if err := s.userManager.RemoveExpiredTokens(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error expiring user tokens")
}
if err := s.userManager.RemoveDeletedUsers(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
}
}).
Debug("Removed expired tokens and users")
}
}
func (s *Server) pruneAttachments() {
if s.fileCache == nil {
return
}
log.
Tag(tagManager).
Timing(func() {
ids, err := s.messageCache.AttachmentsExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
} else if len(ids) > 0 {
if log.Tag(tagManager).IsDebug() {
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.fileCache.Remove(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
}
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired attachments to delete")
}
}).
Debug("Deleted expired attachments")
}
func (s *Server) pruneMessages() {
log.
Tag(tagManager).
Timing(func() {
expiredMessageIDs, err := s.messageCache.MessagesExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
} else if len(expiredMessageIDs) > 0 {
if s.fileCache != nil {
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
}
}
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired messages to delete")
}
}).
Debug("Pruned messages")
}

View File

@ -0,0 +1,28 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {
// Tests that the manager runs without attachment-cache-dir set, see #617
c := newTestConfig(t)
c.AttachmentCacheDir = ""
s := newTestServer(t, c)
// Publish a message
rr := request(t, s, "POST", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
m := toMessage(t, rr.Body.String())
// Expire message
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
// Does not panic
s.pruneMessages()
// Actually deleted
_, err := s.messageCache.Message(m.ID)
require.Equal(t, errMessageNotFound, err)
}

View File

@ -29,7 +29,7 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
if topicCountsAgainst := t.Billee(); topicCountsAgainst != nil {
vRate = topicCountsAgainst
}
r.WithContext(context.WithValue(context.WithValue(r.Context(), "vRate", vRate), "topic", t))
r = r.WithContext(context.WithValue(context.WithValue(r.Context(), "vRate", vRate), "topic", t))
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
return next(w, r, v)

View File

@ -80,14 +80,17 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
return err
}
for _, tier := range tiers {
priceStr, ok := prices[tier.StripePriceID]
if tier.StripePriceID == "" || !ok {
priceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID]
if priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices!
continue
}
response = append(response, &apiAccountBillingTier{
Code: tier.Code,
Name: tier.Name,
Price: priceStr,
Code: tier.Code,
Name: tier.Name,
Prices: &apiAccountBillingPrices{
Month: priceMonth,
Year: priceYear,
},
Limits: &apiAccountLimits{
Basis: string(visitorLimitBasisTier),
Messages: tier.MessageLimit,
@ -117,11 +120,21 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
tier, err := s.userManager.Tier(req.Tier)
if err != nil {
return err
} else if tier.StripePriceID == "" {
}
var priceID string
if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" {
priceID = tier.StripeMonthlyPriceID
} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" {
priceID = tier.StripeYearlyPriceID
} else {
return errNotAPaidTier
}
logvr(v, r).
With(tier).
Fields(log.Context{
"stripe_price_id": priceID,
"stripe_subscription_interval": req.Interval,
}).
Tag(tagStripe).
Info("Creating Stripe checkout flow")
var stripeCustomerID *string
@ -143,7 +156,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
AllowPromotionCodes: stripe.Bool(true),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(tier.StripePriceID),
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
@ -180,10 +193,11 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
sub, err := s.stripe.GetSubscription(sess.Subscription.ID)
if err != nil {
return err
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil {
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil {
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription")
}
tier, err := s.userManager.TierByStripePrice(sub.Items.Data[0].Price.ID)
priceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval
tier, err := s.userManager.TierByStripePrice(priceID)
if err != nil {
return err
}
@ -197,8 +211,10 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
Tag(tagStripe).
Fields(log.Context{
"stripe_customer_id": sess.Customer.ID,
"stripe_price_id": priceID,
"stripe_subscription_id": sub.ID,
"stripe_subscription_status": string(sub.Status),
"stripe_subscription_interval": string(interval),
"stripe_subscription_paid_until": sub.CurrentPeriodEnd,
}).
Info("Stripe checkout flow succeeded, updating user tier and subscription")
@ -213,7 +229,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil {
return err
}
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
return err
}
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
@ -235,15 +251,24 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
if err != nil {
return err
}
var priceID string
if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" {
priceID = tier.StripeMonthlyPriceID
} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" {
priceID = tier.StripeYearlyPriceID
} else {
return errNotAPaidTier
}
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"new_tier_id": tier.ID,
"new_tier_name": tier.Name,
"new_tier_stripe_price_id": tier.StripePriceID,
"new_tier_id": tier.ID,
"new_tier_code": tier.Code,
"new_tier_stripe_price_id": priceID,
"new_tier_stripe_subscription_interval": req.Interval,
// Other stripe_* fields filled by visitor context
}).
Info("Changing Stripe subscription and billing tier to %s/%s (price %s)", tier.ID, tier.Name, tier.StripePriceID)
Info("Changing Stripe subscription and billing tier to %s/%s (price %s, %s)", tier.ID, tier.Name, priceID, req.Interval)
sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID)
if err != nil {
return err
@ -252,11 +277,11 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
}
params := &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(false),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
Items: []*stripe.SubscriptionItemsParams{
{
ID: stripe.String(sub.Items.Data[0].ID),
Price: stripe.String(tier.StripePriceID),
Price: stripe.String(priceID),
},
},
}
@ -345,20 +370,22 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request,
ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
if err != nil {
return err
} else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" {
} else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" || ev.Items.Data[0].Price.Recurring == nil {
logvr(v, r).Tag(tagStripe).Field("stripe_request", fmt.Sprintf("%#v", ev)).Warn("Unexpected request from Stripe")
return errHTTPBadRequestBillingRequestInvalid
}
subscriptionID, priceID := ev.ID, ev.Items.Data[0].Price.ID
subscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"stripe_webhook_type": event.Type,
"stripe_customer_id": ev.Customer,
"stripe_price_id": priceID,
"stripe_subscription_id": ev.ID,
"stripe_subscription_status": ev.Status,
"stripe_subscription_interval": interval,
"stripe_subscription_paid_until": ev.CurrentPeriodEnd,
"stripe_subscription_cancel_at": ev.CancelAt,
"stripe_price_id": priceID,
}).
Info("Updating subscription to status %s, with price %s", ev.Status, priceID)
userFn := func() (*user.User, error) {
@ -376,7 +403,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request,
if err != nil {
return err
}
if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
return err
}
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
@ -399,14 +426,14 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request,
Tag(tagStripe).
Field("stripe_webhook_type", event.Type).
Info("Subscription deleted, downgrading to unpaid tier")
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", 0, 0); err != nil {
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", "", 0, 0); err != nil {
return err
}
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
return nil
}
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error {
reservationsLimit := visitorDefaultReservationsLimit
if tier != nil {
reservationsLimit = tier.ReservationLimit
@ -423,9 +450,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"new_tier_id": tier.ID,
"new_tier_name": tier.Name,
"new_tier_stripe_price_id": tier.StripePriceID,
"new_tier_id": tier.ID,
"new_tier_code": tier.Code,
}).
Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name)
if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {
@ -437,6 +463,7 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
StripeCustomerID: customerID,
StripeSubscriptionID: subscriptionID,
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
}
@ -448,20 +475,16 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
// fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices
// in memory, and ultimately for the web app to display the price table.
func (s *Server) fetchStripePrices() (map[string]string, error) {
func (s *Server) fetchStripePrices() (map[string]int64, error) {
log.Debug("Caching prices from Stripe API")
priceMap := make(map[string]string)
priceMap := make(map[string]int64)
prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)})
if err != nil {
log.Warn("Fetching Stripe prices failed: %s", err.Error())
return nil, err
}
for _, p := range prices {
if p.UnitAmount%100 == 0 {
priceMap[p.ID] = fmt.Sprintf("$%d", p.UnitAmount/100)
} else {
priceMap[p.ID] = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100)
}
priceMap[p.ID] = p.UnitAmount
log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID])
}
return priceMap, nil

View File

@ -37,7 +37,9 @@ func TestPayments_Tiers(t *testing.T) {
On("ListPrices", mock.Anything).
Return([]*stripe.Price{
{ID: "price_123", UnitAmount: 500},
{ID: "price_124", UnitAmount: 5000},
{ID: "price_456", UnitAmount: 1000},
{ID: "price_457", UnitAmount: 10000},
{ID: "price_999", UnitAmount: 9999},
}, nil)
@ -58,7 +60,8 @@ func TestPayments_Tiers(t *testing.T) {
AttachmentFileSizeLimit: 999,
AttachmentTotalSizeLimit: 888,
AttachmentExpiryDuration: time.Minute,
StripePriceID: "price_123",
StripeMonthlyPriceID: "price_123",
StripeYearlyPriceID: "price_124",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_444",
@ -71,7 +74,8 @@ func TestPayments_Tiers(t *testing.T) {
AttachmentFileSizeLimit: 999111,
AttachmentTotalSizeLimit: 888111,
AttachmentExpiryDuration: time.Hour,
StripePriceID: "price_456",
StripeMonthlyPriceID: "price_456",
StripeYearlyPriceID: "price_457",
}))
response := request(t, s, "GET", "/v1/tiers", "", nil)
require.Equal(t, 200, response.Code)
@ -98,6 +102,8 @@ func TestPayments_Tiers(t *testing.T) {
require.Equal(t, "pro", tier.Code)
require.Equal(t, "Pro", tier.Name)
require.Equal(t, "tier", tier.Limits.Basis)
require.Equal(t, int64(500), tier.Prices.Month)
require.Equal(t, int64(5000), tier.Prices.Year)
require.Equal(t, int64(777), tier.Limits.Reservations)
require.Equal(t, int64(1000), tier.Limits.Messages)
require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration)
@ -109,6 +115,8 @@ func TestPayments_Tiers(t *testing.T) {
tier = tiers[2]
require.Equal(t, "business", tier.Code)
require.Equal(t, "Business", tier.Name)
require.Equal(t, int64(1000), tier.Prices.Month)
require.Equal(t, int64(10000), tier.Prices.Year)
require.Equal(t, "tier", tier.Limits.Basis)
require.Equal(t, int64(777333), tier.Limits.Reservations)
require.Equal(t, int64(2000), tier.Limits.Messages)
@ -136,14 +144,14 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
// Create subscription
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
@ -172,9 +180,9 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
@ -187,7 +195,7 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
// Create subscription
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
@ -214,9 +222,9 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
@ -267,7 +275,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "starter",
StripePriceID: "price_1234",
StripeMonthlyPriceID: "price_1234",
ReservationLimit: 1,
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
MessageExpiryDuration: time.Hour,
@ -298,7 +306,12 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
Items: &stripe.SubscriptionItemList{
Data: []*stripe.SubscriptionItem{
{
Price: &stripe.Price{ID: "price_1234"},
Price: &stripe.Price{
ID: "price_1234",
Recurring: &stripe.PriceRecurring{
Interval: stripe.PriceRecurringIntervalMonth,
},
},
},
},
},
@ -333,6 +346,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Equal(t, "", u.Billing.StripeCustomerID)
require.Equal(t, "", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
@ -349,6 +363,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages)
@ -423,7 +438,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "starter",
StripePriceID: "price_1234", // !
StripeMonthlyPriceID: "price_1234", // !
ReservationLimit: 1, // !
MessageLimit: 100,
MessageExpiryDuration: time.Hour,
@ -435,7 +450,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_2",
Code: "pro",
StripePriceID: "price_1111", // !
StripeMonthlyPriceID: "price_1111", // !
ReservationLimit: 3, // !
MessageLimit: 200,
MessageExpiryDuration: time.Hour,
@ -457,6 +472,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(456, 0),
}
@ -499,9 +515,10 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
// Verify that reservations were deleted
r, err := s.userManager.Reservations("phil")
@ -546,10 +563,10 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
// Create a user with a Stripe subscription and 3 reservations
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "pro",
StripePriceID: "price_1234",
ReservationLimit: 1,
ID: "ti_1",
Code: "pro",
StripeMonthlyPriceID: "price_1234",
ReservationLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@ -562,6 +579,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(0, 0),
}))
@ -615,11 +633,11 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
stripeMock.
On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(false),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
Items: []*stripe.SubscriptionItemsParams{
{
ID: stripe.String("someid_123"),
Price: stripe.String("price_456"),
Price: stripe.String("price_457"),
},
},
}).
@ -627,14 +645,16 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
StripeYearlyPriceID: "price_124",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_456",
Code: "business",
StripePriceID: "price_456",
ID: "ti_456",
Code: "business",
StripeMonthlyPriceID: "price_456",
StripeYearlyPriceID: "price_457",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@ -644,7 +664,7 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
}))
// Call endpoint to change subscription
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business"}`, map[string]string{
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
@ -795,7 +815,10 @@ const subscriptionUpdatedEventJSON = `
"data": [
{
"price": {
"id": "price_1234"
"id": "price_1234",
"recurring": {
"interval": "year"
}
}
}
]
@ -818,7 +841,10 @@ const subscriptionDeletedEventJSON = `
"data": [
{
"price": {
"id": "price_1234"
"id": "price_1234",
"recurring": {
"interval": "month"
}
}
}
]

View File

@ -149,6 +149,8 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
require.Equal(t, "", messages[1].Title)
require.Equal(t, 0, messages[1].Priority)
require.Nil(t, messages[1].Tags)
require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires)
require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires)
require.Equal(t, messageEvent, messages[2].Event)
require.Equal(t, "mytopic", messages[2].Topic)
@ -287,6 +289,7 @@ func TestServer_PublishNoCache(t *testing.T) {
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "this message is not cached", msg.Message)
require.Equal(t, int64(0), msg.Expires)
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
@ -324,6 +327,18 @@ func TestServer_PublishAt(t *testing.T) {
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
}
func TestServer_PublishAt_Expires(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"In": "2 days",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix())
require.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix())
}
func TestServer_PublishAtWithCacheError(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@ -1486,7 +1501,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
c.VisitorAttachmentTotalSizeLimit = 10000
s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil)
response := request(t, s, "PUT", "/mytopic", "text file!"+util.RandomString(4990), nil)
msg := toMessage(t, response.Body.String())
require.Equal(t, 200, response.Code)
require.Equal(t, "You received a file: attachment.txt", msg.Message)

View File

@ -37,18 +37,18 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
return err
}
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
logvm(v, m).
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{
"email_via": s.config.SMTPSenderAddr,
"email_user": s.config.SMTPSenderUser,
"email_to": to,
}).
Debug("Sending email")
logvm(v, m).
Tag(tagEmail).
Field("email_body", message).
Trace("Email body")
})
if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email")
} else if ev.IsDebug() {
ev.Debug("Sending email")
}
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
})
}

View File

@ -2,6 +2,7 @@ package server
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"github.com/emersion/go-smtp"
@ -21,9 +22,14 @@ var (
errInvalidAddress = errors.New("invalid address")
errInvalidTopic = errors.New("invalid topic")
errTooManyRecipients = errors.New("too many recipients")
errMultipartNestedTooDeep = errors.New("multipart message nested too deep")
errUnsupportedContentType = errors.New("unsupported content type")
)
const (
maxMultipartDepth = 2
)
// smtpBackend implements SMTP server methods.
type smtpBackend struct {
config *Config
@ -33,6 +39,9 @@ type smtpBackend struct {
mu sync.Mutex
}
var _ smtp.Backend = (*smtpBackend)(nil)
var _ smtp.Session = (*smtpSession)(nil)
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
return &smtpBackend{
config: conf,
@ -40,14 +49,9 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
}
}
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, _ string) (smtp.Session, error) {
logem(state).Debug("Incoming mail, login with user %s", username)
return &smtpSession{backend: b, state: state}, nil
}
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
logem(state).Debug("Incoming mail, anonymous login")
return &smtpSession{backend: b, state: state}, nil
func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
logem(conn).Debug("Incoming mail")
return &smtpSession{backend: b, conn: conn}, nil
}
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
@ -59,24 +63,26 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
// smtpSession is returned after EHLO.
type smtpSession struct {
backend *smtpBackend
state *smtp.ConnectionState
conn *smtp.Conn
topic string
token string
mu sync.Mutex
}
func (s *smtpSession) AuthPlain(username, password string) error {
logem(s.state).Debug("AUTH PLAIN (with username %s)", username)
func (s *smtpSession) AuthPlain(username, _ string) error {
logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
return nil
}
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
logem(s.state).Debug("MAIL FROM: %s (with options: %#v)", from, opts)
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from)
return nil
}
func (s *smtpSession) Rcpt(to string) error {
logem(s.state).Debug("RCPT TO: %s", to)
logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to)
return s.withFailCount(func() error {
token := ""
conf := s.backend.config
addressList, err := mail.ParseAddressList(to)
if err != nil {
@ -88,18 +94,27 @@ func (s *smtpSession) Rcpt(to string) error {
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
return errInvalidDomain
}
// Remove @ntfy.sh from end of email
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
if conf.SMTPServerAddrPrefix != "" {
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
return errInvalidAddress
}
// remove ntfy- from beginning of email
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
}
// If email contains token, split topic and token
if strings.Contains(to, "+") {
parts := strings.Split(to, "+")
to = parts[0]
token = parts[1]
}
if !topicRegex.MatchString(to) {
return errInvalidTopic
}
s.mu.Lock()
s.topic = to
s.token = token
s.mu.Unlock()
return nil
})
@ -112,17 +127,17 @@ func (s *smtpSession) Data(r io.Reader) error {
if err != nil {
return err
}
ev := logem(s.state).Tag(tagSMTP)
ev := logem(s.conn)
if ev.IsTrace() {
ev.Field("smtp_data", string(b)).Trace("DATA")
} else if ev.IsDebug() {
ev.Debug("DATA: %d byte(s)", len(b))
ev.Field("smtp_data_len", len(b)).Debug("DATA")
}
msg, err := mail.ReadMessage(bytes.NewReader(b))
if err != nil {
return err
}
body, err := readMailBody(msg)
body, err := readMailBody(msg.Body, msg.Header)
if err != nil {
return err
}
@ -156,11 +171,10 @@ func (s *smtpSession) Data(r io.Reader) error {
func (s *smtpSession) publishMessage(m *message) error {
// Extract remote address (for rate limiting)
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil {
remoteAddr = s.state.RemoteAddr.String()
remoteAddr = s.conn.Conn().RemoteAddr().String()
}
// Call HTTP handler with fake HTTP request
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
@ -173,6 +187,9 @@ func (s *smtpSession) publishMessage(m *message) error {
if m.Title != "" {
req.Header.Set("Title", m.Title)
}
if s.token != "" {
req.Header.Add("Authorization", "Bearer "+s.token)
}
rr := httptest.NewRecorder()
s.backend.handler(rr, req)
if rr.Code != http.StatusOK {
@ -198,54 +215,58 @@ func (s *smtpSession) withFailCount(fn func() error) error {
if err != nil {
// Almost all of these errors are parse errors, and user input errors.
// We do not want to spam the log with WARN messages.
logem(s.state).Err(err).Debug("Incoming mail error")
logem(s.conn).Err(err).Debug("Incoming mail error")
s.backend.failure++
}
return err
}
func readMailBody(msg *mail.Message) (string, error) {
if msg.Header.Get("Content-Type") == "" {
return readPlainTextMailBody(msg)
func readMailBody(body io.Reader, header mail.Header) (string, error) {
if header.Get("Content-Type") == "" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
}
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
contentType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
if err != nil {
return "", err
}
if contentType == "text/plain" {
return readPlainTextMailBody(msg)
} else if strings.HasPrefix(contentType, "multipart/") {
return readMultipartMailBody(msg, params)
if strings.ToLower(contentType) == "text/plain" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
return readMultipartMailBody(body, params, 0)
}
return "", errUnsupportedContentType
}
func readPlainTextMailBody(msg *mail.Message) (string, error) {
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) {
if depth >= maxMultipartDepth {
return "", errMultipartNestedTooDeep
}
return string(body), nil
}
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
mr := multipart.NewReader(msg.Body, params["boundary"])
mr := multipart.NewReader(body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if partContentType != "text/plain" {
continue
if strings.ToLower(partContentType) == "text/plain" {
return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
return readMultipartMailBody(part, partParams, depth+1)
}
body, err := io.ReadAll(part)
if err != nil {
return "", err
}
return string(body), nil
// Continue with next part
}
}
func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
if strings.ToLower(transferEncoding) == "base64" {
reader = base64.NewDecoder(base64.StdEncoding, reader)
}
body, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return string(body), nil
}

View File

@ -1,16 +1,23 @@
package server
import (
"bufio"
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
)
func TestSmtpBackend_Multipart(t *testing.T) {
email := `MIME-Version: 1.0
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
MIME-Version: 1.0
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more
@ -28,20 +35,25 @@ Content-Type: text/html; charset="UTF-8"
<div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div>
--000000000000f3320b05d42915c9--`
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
--000000000000f3320b05d42915c9--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "and one more", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body))
})
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
email := `MIME-Version: 1.0
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-emailtest@ntfy.sh
DATA
MIME-Version: 1.0
Date: Tue, 28 Dec 2021 01:33:34 +0100
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
Subject: This email has a subject but no body
@ -59,20 +71,25 @@ Content-Type: text/html; charset="UTF-8"
<div dir="ltr"><br></div>
--000000000000bcf4a405d429f8d4--`
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
--000000000000bcf4a405d429f8d4--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/emailtest", r.URL.Path)
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
})
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Plaintext(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more
From: Phil <phil@example.com>
@ -80,56 +97,68 @@ To: mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8"
what's up
.
`
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "and one more", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body))
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
email := `Subject: Very short mail
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Subject: Very short mail
what's up
.
`
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Very short mail", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body))
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
From: Phil <phil@example.com>
To: ntfy-mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8"
what's up
.
`
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
})
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more
From: Phil <phil@example.com>
@ -148,60 +177,61 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
that should do it
.
`
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
expected := `you know this is a string.
it's a long string.
it's supposed to be longer than the max message length
@ -214,68 +244,71 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBB`
require.Equal(t, 4096, len(expected)) // Sanity check
require.Equal(t, expected, readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Unsupported(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more
From: Phil <phil@example.com>
@ -283,34 +316,254 @@ To: mytopic@ntfy.sh
Content-Type: text/SOMETHINGELSE
what's up
.
`
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
// Nothing.
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("This should not be called")
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type")
}
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
conf := newTestConfig(t)
func TestSmtpBackend_InvalidAddress(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: unsupported@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Subject: and one more
From: Phil <phil@example.com>
To: mytopic@ntfy.sh
Content-Type: text/plain
what's up
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("This should not be called")
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address")
}
func TestSmtpBackend_Base64Body(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Content-Type: multipart/mixed; boundary="===============2138658284696597373=="
MIME-Version: 1.0
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
From: =?utf-8?q?Robbie?= <test@mydomain.me>
To: test@mydomain.me
Date: Thu, 16 Feb 2023 01:04:00 -0000
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
This is a multi-part message in MIME format.
--===============2138658284696597373==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
--===============2138658284696597373==
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
--===============2138658284696597373==--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title"))
require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_NestedMultipartBase64(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Content-Type: multipart/mixed; boundary="===============2138658284696597373=="
MIME-Version: 1.0
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
From: =?utf-8?q?Robbie?= <test@mydomain.me>
To: test@mydomain.me
Date: Thu, 16 Feb 2023 01:04:00 -0000
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
This is a multi-part message in MIME format.
--===============2138658284696597373==
Content-Type: multipart/alternative; boundary="===============2233989480071754745=="
MIME-Version: 1.0
--===============2233989480071754745==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
--===============2233989480071754745==
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
--===============2233989480071754745==--
--===============2138658284696597373==--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title"))
require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Content-Type: multipart/mixed; boundary="===============1=="
MIME-Version: 1.0
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
From: =?utf-8?q?Robbie?= <test@mydomain.me>
To: test@mydomain.me
Date: Thu, 16 Feb 2023 01:04:00 -0000
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
This is a multi-part message in MIME format.
--===============1==
Content-Type: multipart/alternative; boundary="===============2=="
MIME-Version: 1.0
--===============2==
Content-Type: multipart/alternative; boundary="===============3=="
MIME-Version: 1.0
--===============3==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
--===============3==
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
--===============3==--
--===============2==--
--===============1==--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("This should not be called")
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep")
}
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic+tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2@ntfy.sh
DATA
Subject: Very short mail
what's up
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Very short mail", r.Header.Get("Title"))
require.Equal(t, "Bearer tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2", r.Header.Get("Authorization"))
require.Equal(t, "what's up", readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
conf = newTestConfig(t)
conf.SMTPServerListen = ":25"
conf.SMTPServerDomain = "ntfy.sh"
conf.SMTPServerAddrPrefix = "ntfy-"
backend := newMailBackend(conf, handler)
return conf, backend
}
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
ip, err := net.ResolveIPAddr("ip", remoteAddr)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
return &smtp.ConnectionState{
Hostname: "myhostname",
LocalAddr: ip,
RemoteAddr: ip,
s = smtp.NewServer(backend)
s.Domain = conf.SMTPServerDomain
s.AllowInsecureAuth = true
go func() {
require.Nil(t, s.Serve(l))
}()
c, err = net.Dial("tcp", l.Addr().String())
if err != nil {
t.Fatal(err)
}
scanner = bufio.NewScanner(c)
return
}
func writeAndReadUntilLine(t *testing.T, email string, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
_, err := io.WriteString(conn, email)
require.Nil(t, err)
readUntilLine(t, conn, scanner, expectedLine)
}
func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
cancelChan := make(chan bool)
go func() {
select {
case <-cancelChan:
case <-time.After(3 * time.Second):
conn.Close()
t.Error("Failed waiting for expected output")
}
}()
var output string
for scanner.Scan() {
text := scanner.Text()
if strings.TrimSpace(text) == expectedLine {
cancelChan <- true
return
}
output += text + "\n"
//fmt.Println(text)
}
t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output)
}

View File

@ -309,6 +309,7 @@ type apiAccountBilling struct {
Customer bool `json:"customer"`
Subscription bool `json:"subscription"`
Status string `json:"status,omitempty"`
Interval string `json:"interval,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"`
CancelAt int64 `json:"cancel_at,omitempty"`
}
@ -343,11 +344,16 @@ type apiConfigResponse struct {
DisallowedTopics []string `json:"disallowed_topics"`
}
type apiAccountBillingPrices struct {
Month int64 `json:"month"`
Year int64 `json:"year"`
}
type apiAccountBillingTier struct {
Code string `json:"code,omitempty"`
Name string `json:"name,omitempty"`
Price string `json:"price,omitempty"`
Limits *apiAccountLimits `json:"limits"`
Code string `json:"code,omitempty"`
Name string `json:"name,omitempty"`
Prices *apiAccountBillingPrices `json:"prices,omitempty"`
Limits *apiAccountLimits `json:"limits"`
}
type apiAccountBillingSubscriptionCreateResponse struct {
@ -355,7 +361,8 @@ type apiAccountBillingSubscriptionCreateResponse struct {
}
type apiAccountBillingSubscriptionChangeRequest struct {
Tier string `json:"tier"`
Tier string `json:"tier"`
Interval string `json:"interval"`
}
type apiAccountBillingPortalRedirectResponse struct {
@ -385,7 +392,10 @@ type apiStripeSubscriptionUpdatedEvent struct {
Items *struct {
Data []*struct {
Price *struct {
ID string `json:"id"`
ID string `json:"id"`
Recurring *struct {
Interval string `json:"interval"`
} `json:"recurring"`
} `json:"price"`
} `json:"data"`
} `json:"items"`

View File

@ -1,13 +1,11 @@
package server
import (
"heckel.io/ntfy/util"
"io"
"net/http"
"net/netip"
"strings"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@ -74,7 +72,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
if err != nil {
ip = netip.IPv4Unspecified()
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err)
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
}
}
}
@ -85,7 +83,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
if err != nil {
log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error())
logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
// Fall back to regular remote address if X-Forwarded-For is damaged
} else {
ip = realIP

View File

@ -159,8 +159,9 @@ func (v *visitor) contextNoLock() log.Context {
fields["user_id"] = v.user.ID
fields["user_name"] = v.user.Name
if v.user.Tier != nil {
fields["tier_id"] = v.user.Tier.ID
fields["tier_name"] = v.user.Tier.Name
for field, value := range v.user.Tier.Context() {
fields[field] = value
}
}
if v.user.Billing.StripeCustomerID != "" {
fields["stripe_customer_id"] = v.user.Billing.StripeCustomerID
@ -329,9 +330,13 @@ func (v *visitor) SetUser(u *user.User) {
v.mu.Lock()
defer v.mu.Unlock()
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
v.user = u
v.user = u // u may be nil!
if shouldResetLimiters {
v.resetLimitersNoLock(0, 0, true)
var messages, emails int64
if u != nil {
messages, emails = u.Stats.Messages, u.Stats.Emails
}
v.resetLimitersNoLock(messages, emails, true)
}
}

View File

@ -46,7 +46,8 @@ var (
// Manager-related queries
const (
createTablesQueriesNoTx = `
createTablesQueries = `
BEGIN;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
@ -59,10 +60,12 @@ const (
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_price_id TEXT
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
@ -76,6 +79,7 @@ const (
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
@ -112,33 +116,33 @@ const (
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
COMMIT;
`
createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;`
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ?
`
selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
`
selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ?
@ -246,27 +250,27 @@ const (
`
insertTierQuery = `
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
updateTierQuery = `
UPDATE tier
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_price_id = ?
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
WHERE code = ?
`
selectTiersQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
`
selectTierByCodeQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE code = ?
`
selectTierByPriceIDQuery = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE stripe_price_id = ?
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
`
updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?`
deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?`
@ -274,21 +278,86 @@ const (
updateBillingQuery = `
UPDATE user
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
WHERE user = ?
`
)
// Schema management queries
const (
currentSchemaVersion = 2
currentSchemaVersion = 3
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
// 1 -> 2 (complex migration!)
migrate1To2RenameUserTableQueryNoTx = `
migrate1To2CreateTablesQueries = `
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
`
migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old`
migrate1To2InsertUserNoTx = `
@ -304,11 +373,22 @@ const (
DROP TABLE access;
DROP TABLE user_old;
`
// 2 -> 3
migrate2To3UpdateQueries = `
ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT;
ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id;
ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT;
DROP INDEX IF EXISTS idx_tier_price_id;
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
`
)
var (
migrations = map[int]func(db *sql.DB) error{
1: migrateFrom1,
2: migrateFrom2,
}
)
@ -805,13 +885,13 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close()
var id, username, hash, role, prefs, syncTopic string
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierID, tierCode, tierName sql.NullString
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() {
return nil, ErrUserNotFound
}
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripePriceID); err != nil {
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
@ -828,11 +908,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
Emails: emails,
},
Billing: &Billing{
StripeCustomerID: stripeCustomerID.String, // May be empty
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
StripeCustomerID: stripeCustomerID.String, // May be empty
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
},
Deleted: deleted.Valid,
}
@ -853,7 +934,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,
StripePriceID: stripePriceID.String, // May be empty
StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty
StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty
}
}
return user, nil
@ -1134,7 +1216,7 @@ func (a *Manager) AddTier(tier *Tier) error {
if tier.ID == "" {
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
}
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripePriceID)); err != nil {
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
return err
}
return nil
@ -1142,7 +1224,7 @@ func (a *Manager) AddTier(tier *Tier) error {
// UpdateTier updates a tier's properties in the database
func (a *Manager) UpdateTier(tier *Tier) error {
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripePriceID), tier.Code); err != nil {
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
return err
}
return nil
@ -1162,7 +1244,7 @@ func (a *Manager) RemoveTier(code string) error {
// ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information
func (a *Manager) ChangeBilling(username string, billing *Billing) error {
if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
return err
}
return nil
@ -1200,7 +1282,7 @@ func (a *Manager) Tier(code string) (*Tier, error) {
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
rows, err := a.db.Query(selectTierByPriceIDQuery, priceID)
rows, err := a.db.Query(selectTierByPriceIDQuery, priceID, priceID)
if err != nil {
return nil, err
}
@ -1210,12 +1292,12 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
var id, code, name string
var stripePriceID sql.NullString
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
if !rows.Next() {
return nil, ErrTierNotFound
}
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripePriceID); err != nil {
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
@ -1233,7 +1315,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,
StripePriceID: stripePriceID.String, // May be empty
StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty
StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty
}, nil
}
@ -1313,10 +1396,7 @@ func migrateFrom1(db *sql.DB) error {
}
defer tx.Rollback()
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(migrate1To2RenameUserTableQueryNoTx); err != nil {
return err
}
if _, err := tx.Exec(createTablesQueriesNoTx); err != nil {
if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
@ -1356,6 +1436,22 @@ func migrateFrom1(db *sql.DB) error {
return nil
}
func migrateFrom2(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 3); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}

View File

@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/util"
"net/netip"
@ -113,7 +114,8 @@ func TestManager_AddUser_And_Query(t *testing.T) {
require.Nil(t, a.ChangeBilling("user", &Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
StripeSubscriptionStatus: "active",
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
StripeSubscriptionCancelAt: time.Unix(0, 0),
}))
@ -395,7 +397,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
require.Nil(t, a.AddTier(&Tier{
Code: "pro",
Name: "ntfy Pro",
StripePriceID: "price123",
StripeMonthlyPriceID: "price123",
MessageLimit: 5_000,
MessageExpiryDuration: 3 * 24 * time.Hour,
EmailLimit: 50,
@ -761,7 +763,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
AttachmentTotalSizeLimit: 1,
AttachmentExpiryDuration: time.Second,
AttachmentBandwidthLimit: 1,
StripePriceID: "price_1",
StripeMonthlyPriceID: "price_1",
}))
require.Nil(t, a.AddTier(&Tier{
Code: "pro",
@ -774,7 +776,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
AttachmentTotalSizeLimit: 123123,
AttachmentExpiryDuration: 10800 * time.Second,
AttachmentBandwidthLimit: 21474836480,
StripePriceID: "price_2",
StripeMonthlyPriceID: "price_2",
}))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.ChangeTier("phil", "pro"))
@ -800,7 +802,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)
require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_2", ti.StripePriceID)
require.Equal(t, "price_2", ti.StripeMonthlyPriceID)
// Update tier
ti.EmailLimit = 999999
@ -822,7 +824,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)
require.Equal(t, time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(1), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_1", ti.StripePriceID)
require.Equal(t, "price_1", ti.StripeMonthlyPriceID)
ti = tiers[1]
require.Equal(t, "pro", ti.Code)
@ -835,7 +837,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)
require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_2", ti.StripePriceID)
require.Equal(t, "price_2", ti.StripeMonthlyPriceID)
ti, err = a.TierByStripePrice("price_1")
require.Nil(t, err)
@ -849,7 +851,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)
require.Equal(t, time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(1), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_1", ti.StripePriceID)
require.Equal(t, "price_1", ti.StripeMonthlyPriceID)
// Cannot remove tier, since user has this tier
require.Error(t, a.RemoveTier("pro"))

View File

@ -91,15 +91,17 @@ type Tier struct {
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
AttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted
AttachmentBandwidthLimit int64 // Daily bandwidth limit for the user
StripePriceID string // Price ID for paid tiers (price_...)
StripeMonthlyPriceID string // Monthly price ID for paid tiers (price_...)
StripeYearlyPriceID string // Yearly price ID for paid tiers (price_...)
}
// Context returns fields for the log
func (t *Tier) Context() log.Context {
return log.Context{
"tier_id": t.ID,
"tier_code": t.Code,
"stripe_price_id": t.StripePriceID,
"tier_id": t.ID,
"tier_code": t.Code,
"stripe_monthly_price_id": t.StripeMonthlyPriceID,
"stripe_yearly_price_id": t.StripeYearlyPriceID,
}
}
@ -136,6 +138,7 @@ type Billing struct {
StripeCustomerID string
StripeSubscriptionID string
StripeSubscriptionStatus stripe.SubscriptionStatus
StripeSubscriptionInterval stripe.PriceRecurringInterval
StripeSubscriptionPaidUntil time.Time
StripeSubscriptionCancelAt time.Time
}

View File

@ -49,12 +49,15 @@ func TestAllowedTier(t *testing.T) {
func TestTierContext(t *testing.T) {
tier := &Tier{
ID: "ti_abc",
Code: "pro",
StripePriceID: "price_123",
ID: "ti_abc",
Code: "pro",
StripeMonthlyPriceID: "price_123",
StripeYearlyPriceID: "price_456",
}
context := tier.Context()
require.Equal(t, "ti_abc", context["tier_id"])
require.Equal(t, "pro", context["tier_code"])
require.Equal(t, "price_123", context["stripe_price_id"])
require.Equal(t, "price_123", context["stripe_monthly_price_id"])
require.Equal(t, "price_456", context["stripe_yearly_price_id"])
}

View File

@ -379,7 +379,7 @@ func String(v string) *string {
return &v
}
// Int turns a string into a pointer of an int
// Int turns an int into a pointer of an int
func Int(v int) *int {
return &v
}

13627
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,11 @@
// During web development, you may change values here for rapid testing.
var config = {
base_url: "https://127.0.0.1", // window.location.origin FIXME update before merging
base_url: "https://127.0.0.1", // to test against a different server
app_root: "/app",
enable_login: true,
enable_signup: true,
enable_payments: true,
enable_reservations: true,
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login"]
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
};

View File

@ -0,0 +1,281 @@
{
"action_bar_logo_alt": "شعار ntfy",
"action_bar_settings": "اﻹعدادات",
"action_bar_clear_notifications": "محو كافة الإشعارات",
"action_bar_unsubscribe": "إلغاء الاشتراك",
"message_bar_show_dialog": "إظهار مربع حوار النشر",
"message_bar_publish": "نشر الرسالة",
"nav_topics_title": "المواضيع التي تم الاشتراك فيها",
"nav_button_all_notifications": "كافة الإشعارات",
"nav_button_settings": "اﻹعدادات",
"nav_button_documentation": "الدليل",
"nav_button_publish_message": "نشر الإشعار",
"nav_button_subscribe": "اشترك في الموضوع",
"nav_button_connecting": "جارٍ الاتصال",
"alert_grant_title": "تم تعطيل الإشعارات",
"alert_grant_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
"notifications_list": "قائمة الإشعارات",
"notifications_list_item": "إشعار",
"notifications_mark_read": "وضع علامة كمقروء",
"notifications_tags": "الوسوم",
"notifications_priority_x": "الأولوية {{priority}}",
"notifications_new_indicator": "إشعار جديد",
"notifications_attachment_image": "صورة مرفقة",
"notifications_attachment_copy_url_button": "نسخ عنوان URL",
"notifications_attachment_open_title": "انتقل إلى {{url}}",
"notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}",
"notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل",
"notifications_attachment_file_image": "ملف الصورة",
"notifications_attachment_file_video": "ملف فيديو",
"notifications_attachment_file_audio": "ملف صوتي",
"notifications_attachment_file_app": "ملف تطبيق Android",
"notifications_attachment_file_document": "وثيقة أخرى",
"notifications_click_copy_url_button": "نسخ الرابط",
"notifications_click_open_button": "فتح الرابط",
"notifications_actions_open_url_title": "انتقل إلى {{url}}",
"notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب",
"action_bar_send_test_notification": "إرسال إشعار للاختبار",
"action_bar_show_menu": "عرض القائمة",
"message_bar_type_message": "اكتب رسالة هنا",
"alert_not_supported_title": "الإشعارات غير مدعومة",
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
"notifications_delete": "حذف",
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
"action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات",
"alert_grant_button": "امنح الآن",
"notifications_attachment_open_button": "فتح المرفق",
"notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة",
"notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة",
"notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.",
"notifications_none_for_any_title": "لم تتلق أية إشعارات.",
"notifications_no_subscriptions_title": "يبدو أنك لا تملك أي اشتراكات بعد.",
"notifications_example": "مثال",
"notifications_loading": "تحميل الإشعارات…",
"publish_dialog_title_topic": "أنشُر إلى {{topic}}",
"publish_dialog_title_no_topic": "انشُر الإشعار",
"publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا",
"publish_dialog_priority_min": "الحد الأدنى للأولوية",
"publish_dialog_priority_low": "أولوية منخفضة",
"publish_dialog_priority_default": "الأولوية الافتراضية",
"publish_dialog_priority_high": "أولوية عالية",
"publish_dialog_base_url_label": "الرابط التشعبي للخدمة",
"publish_dialog_priority_max": "الأولوية القصوى",
"publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
"publish_dialog_title_label": "العنوان",
"publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص",
"publish_dialog_message_label": "الرسالة",
"publish_dialog_message_placeholder": "اكتب رسالة هنا",
"publish_dialog_tags_label": "الوسوم",
"publish_dialog_priority_label": "الأولوية",
"publish_dialog_click_placeholder": "العنوان التشعبي URL الذي يتم فتحه عند النقر فوق الإشعار",
"publish_dialog_email_label": "البريد الإلكتروني",
"publish_dialog_filename_label": "اسم الملف",
"publish_dialog_attach_label": "الرابط التشعبي URL للمرفق",
"publish_dialog_filename_placeholder": "اسم ملف المرفق",
"publish_dialog_delay_label": "تأخير",
"publish_dialog_delay_reset": "إزالة تأخر التسليم",
"publish_dialog_chip_click_label": "انقر على عنوان URL",
"publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني",
"publish_dialog_chip_attach_file_label": "إرفاق ملف محلي",
"publish_dialog_chip_topic_label": "تغيير الموضوع",
"publish_dialog_button_cancel_sending": "إلغاء الإرسال",
"publish_dialog_button_send": "أرسل",
"publish_dialog_checkbox_publish_another": "نشر آخر",
"publish_dialog_attached_file_title": "الملف المرفق:",
"publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق",
"publish_dialog_attached_file_remove": "إزالة الملف المرفق",
"publish_dialog_drop_file_here": "قم بإسقاط ملف هنا",
"emoji_picker_search_placeholder": "البحث عن رمز تعبيري",
"emoji_picker_search_clear": "مسح البحث",
"subscribe_dialog_subscribe_title": "الإشتراك في الموضوع",
"subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر",
"subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة",
"subscribe_dialog_subscribe_button_subscribe": "اشترِك",
"subscribe_dialog_login_title": "تسجيل الدخول مطلوب",
"subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil",
"subscribe_dialog_login_password_label": "كلمة المرور",
"subscribe_dialog_login_button_login": "الولوج",
"subscribe_dialog_error_user_anonymous": "مجهول",
"prefs_notifications_title": "الإشعارات",
"prefs_notifications_sound_title": "صوت الإشعار",
"prefs_notifications_sound_no_sound": "لا صوت",
"prefs_notifications_min_priority_description_any": "عرض جميع الإشعارات، بغض النظر عن الأولوية",
"prefs_notifications_delete_after_title": "حذف الإشعارات",
"prefs_notifications_delete_after_never": "أبداً",
"prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات",
"prefs_notifications_delete_after_one_day": "بعد يوم واحد",
"prefs_notifications_delete_after_one_month": "بعد شهر واحد",
"prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا",
"prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
"prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد",
"prefs_users_table": "قائمة المستخدمين",
"prefs_users_edit_button": "تعديل المستخدم",
"prefs_users_table_user_header": "المستخدم",
"prefs_users_table_base_url_header": "الرابط التشعبي للخدمة",
"priority_default": "افتراضية",
"prefs_users_dialog_username_label": "اسم المستخدم، على سبيل المثال phil",
"prefs_users_dialog_button_cancel": "إلغاء",
"prefs_users_dialog_button_add": "اضافة",
"prefs_users_dialog_button_save": "حفظ",
"prefs_appearance_title": "المظهر",
"prefs_appearance_language_title": "اللغة",
"error_boundary_gathering_info": "جمع مزيد من المعلومات …",
"error_boundary_unsupported_indexeddb_title": "التصفح الخاص غير مدعوم",
"priority_high": "عالية",
"priority_max": "قصوى",
"error_boundary_title": "أوه لا ، لقد تحطم ntfy",
"prefs_users_delete_button": "حذف المستخدم",
"prefs_users_add_button": "إضافة مستخدم",
"prefs_notifications_min_priority_any": "مهما كانت الأولوية",
"prefs_notifications_delete_after_one_week": "بعد أسبوع واحد",
"prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات",
"prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
"prefs_users_title": "إدارة المستخدمين",
"prefs_users_dialog_title_add": "إضافة مستخدم",
"prefs_users_dialog_title_edit": "تعديل المستخدم",
"prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh",
"publish_dialog_button_cancel": "إلغاء",
"publish_dialog_message_published": "تم نشر الإشعار",
"prefs_users_dialog_password_label": "كلمة المرور",
"publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com",
"publish_dialog_progress_uploading": "جارٍ التحميل…",
"publish_dialog_topic_label": "اسم الموضوع",
"publish_dialog_topic_reset": "إعادة تعيين الموضوع",
"publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني",
"publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com",
"publish_dialog_other_features": "ميزات أخرى:",
"publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL",
"subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
"prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها",
"publish_dialog_chip_delay_label": "تأخير التسليم",
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
"subscribe_dialog_login_button_back": "العودة",
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
"prefs_notifications_min_priority_title": "الحد الأدنى للأولوية",
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
"notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.",
"publish_dialog_click_label": "الرابط التشعبي URL للنقر",
"publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup",
"publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "إزالة عنوان URL للمرفق",
"subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به",
"common_save": "حفظ",
"common_add": "إضافة",
"signup_form_username": "إسم المستخدم",
"signup_form_confirm_password": "تأكيد كلمة المرور",
"login_title": "تسجيل الدخول إلى حسابك ntfy",
"login_form_button_submit": "الولوج",
"login_link_signup": "إنشاء حساب",
"login_disabled": "تم تعطيل تسجيل الدخول",
"action_bar_account": "الحساب",
"action_bar_change_display_name": "تغيير الإسم المعروض",
"signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات",
"action_bar_reservation_add": "حجز الموضوع",
"action_bar_reservation_edit": "تغيير الحجز",
"action_bar_profile_title": "الملف التعريفي",
"action_bar_profile_settings": "اﻹعدادات",
"action_bar_profile_logout": "الخروج",
"action_bar_sign_in": "الولوج",
"action_bar_sign_up": "إنشاء حساب",
"nav_button_account": "الحساب",
"nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro",
"reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول",
"subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم",
"subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل",
"account_basics_title": "الحساب",
"account_basics_username_title": "إسم المستخدم",
"account_basics_username_description": "مرحبًا، هذا أنت ❤",
"account_basics_username_admin_tooltip": "أنت مدير",
"account_basics_password_title": "كلمة المرور",
"account_basics_password_description": "غيّر كلمة مرور حسابك",
"account_basics_password_dialog_title": "تغيير كلمة المرور",
"account_basics_password_dialog_current_password_label": "كلمة المرور الحالية",
"account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة",
"account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور",
"account_basics_password_dialog_button_submit": "تغيير كلمة المرور",
"account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة",
"account_usage_title": "الإستخدام",
"account_usage_of_limit": "من {{limit}}",
"account_usage_unlimited": "غير محدود",
"account_basics_tier_title": "نوع الحساب",
"account_basics_tier_description": "مستوى قوة حسابك",
"account_basics_tier_admin": "مدير",
"account_basics_tier_free": "مجاني",
"account_basics_tier_upgrade_button": "الترقية إلى Pro",
"account_basics_tier_change_button": "تغيير",
"account_basics_tier_manage_billing_button": "إدارة الفوترة",
"account_usage_messages_title": "الرسائل المنشورة",
"account_usage_reservations_title": "المواضيع المحجوزة",
"account_usage_attachment_storage_title": "تخزين المرفقات",
"account_delete_title": "حذف الحساب",
"account_delete_description": "احذف حسابك نهائيا",
"account_delete_dialog_label": "كلمة المرور",
"account_upgrade_dialog_title": "تغيير فئة الحساب",
"account_upgrade_dialog_tier_features_messages": "{{messages}} رسائل يومية",
"account_upgrade_dialog_tier_features_emails": "{{emails}} من رسائل البريد الإلكتروني اليومية",
"account_upgrade_dialog_button_cancel": "إلغاء",
"account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك",
"account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك",
"account_tokens_title": "رموز الوصول",
"account_tokens_table_token_header": "الرمز المميز",
"account_tokens_table_last_access_header": "آخر وصول",
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
"account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة",
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
"account_tokens_dialog_title_create": "إنشاء رمز مميز للوصول",
"account_tokens_dialog_title_edit": "تعديل الرمز المميز للوصول",
"account_tokens_dialog_title_delete": "حذف الرمز المميز للوصول",
"account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار",
"account_tokens_dialog_button_create": "إنشاء رمز مميز",
"account_tokens_dialog_button_update": "تحديث الرمز المميز",
"account_tokens_dialog_button_cancel": "إلغاء",
"account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في",
"account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير",
"account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات",
"account_tokens_dialog_expires_never": "لا تنتهي صلاحية الرمز المميز أبدًا",
"account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول",
"account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا",
"prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول",
"prefs_reservations_add_button": "إضافة موضوع محجوز",
"prefs_reservations_table": "جدول المواضيع المحجوزة",
"prefs_reservations_table_topic_header": "الموضوع",
"prefs_reservations_table_access_header": "الوصول",
"prefs_reservations_table_everyone_deny_all": "أنا فقط من يستطيع النشر والاشتراك",
"prefs_reservations_table_everyone_write_only": "يمكنني النشر والاشتراك ، ويمكن للجميع النشر",
"prefs_reservations_table_everyone_read_write": "يمكن للجميع النشر والاشتراك",
"prefs_reservations_table_not_subscribed": "غير مشترك",
"prefs_reservations_dialog_title_edit": "تحرير الموضوع المحجوز",
"prefs_reservations_dialog_topic_label": "الموضوع",
"prefs_reservations_dialog_access_label": "الوصول",
"reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا",
"reservation_delete_dialog_submit_button": "حذف الحجز",
"signup_title": "إنشاء حساب ntfy",
"common_cancel": "إلغاء",
"signup_form_password": "كلمة المرور",
"signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!",
"signup_form_button_submit": "إنشاء حساب",
"signup_disabled": "تم تعطيل التسجيل",
"display_name_dialog_placeholder": "الإسم المعروض",
"display_name_dialog_title": "تغيير الإسم المعروض",
"account_basics_tier_basic": "أساسي",
"account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة",
"account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب",
"account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة",
"account_delete_dialog_button_cancel": "إلغاء",
"account_delete_dialog_button_submit": "حذف الحساب نهائيا",
"account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك",
"account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول",
"prefs_reservations_title": "المواضيع المحجوزة",
"prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك",
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
"action_bar_reservation_delete": "إزالة الحجز",
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر."
}

View File

@ -187,5 +187,35 @@
"prefs_users_table": "Таблица с потребители",
"prefs_users_edit_button": "Промяна на потребител",
"error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа",
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>."
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"signup_title": "Създаване на профил в ntfy",
"signup_form_username": "Потребител",
"signup_form_password": "Парола",
"signup_form_button_submit": "Регистриране",
"signup_form_toggle_password_visibility": "Превключване видимостта на паролата",
"signup_already_have_account": "Имате профил? Впишете се!",
"signup_error_username_taken": "Потребителското име {{username}} е заето",
"login_title": "Впишете се в профила си в ntfy",
"login_form_button_submit": "Вписване",
"login_link_signup": "Регистриране",
"login_disabled": "Вписването е изключено",
"action_bar_account": "Профил",
"action_bar_change_display_name": "Промяна на показваното име",
"action_bar_reservation_add": "Резервиране на тема",
"action_bar_reservation_delete": "Премахване на резервацията",
"action_bar_reservation_limit_reached": "Ограничението е достигнато",
"action_bar_profile_title": "Профил",
"action_bar_profile_settings": "Настройки",
"action_bar_profile_logout": "Изход",
"action_bar_sign_in": "Вписване",
"nav_button_account": "Профил",
"nav_upgrade_banner_label": "Надграждане до ntfy Pro",
"signup_form_confirm_password": "Парола отново",
"signup_disabled": "Регистрациите са затворени",
"signup_error_creation_limit_reached": "Достигнатео е ограничението за създаване на профили",
"display_name_dialog_title": "Промяна на показваното име",
"action_bar_reservation_edit": "Промяна на резервацията",
"action_bar_sign_up": "Регистриране",
"account_basics_title": "Профил",
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>."
}

View File

@ -187,5 +187,35 @@
"prefs_notifications_sound_play": "Přehrát vybraný zvuk",
"prefs_users_table": "Tabulka uživatelů",
"notifications_attachment_file_document": "jiný dokument",
"publish_dialog_delay_reset": "Odebrat odložené doručení"
"publish_dialog_delay_reset": "Odebrat odložené doručení",
"signup_form_confirm_password": "Potvrdit heslo",
"signup_form_button_submit": "Zaregistrovat",
"signup_form_username": "Jméno",
"signup_form_toggle_password_visibility": "Zobrazit heslo",
"signup_already_have_account": "Už máte účet? Přihlašte se!",
"signup_error_username_taken": "Jméno {{username}} už je zabráno",
"signup_error_creation_limit_reached": "Dosažen limit tvorby účtů",
"login_title": "Přihlaste se do svého ntfy účtu",
"login_form_button_submit": "Přihlásit se",
"login_link_signup": "Přihlásit se",
"login_disabled": "Přihlašování je zakázáno",
"action_bar_account": "Účet",
"action_bar_reservation_add": "Zarezervovat téma",
"action_bar_reservation_edit": "Změnit rezervaci",
"action_bar_reservation_delete": "Odstranit rezervaci",
"action_bar_reservation_limit_reached": "Limit dosažen",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Nastavení",
"action_bar_profile_logout": "Odhlásit se",
"action_bar_sign_up": "Zaregistrovat se",
"nav_button_account": "Účet",
"nav_upgrade_banner_label": "Upgradovat na nfty Pro",
"nav_upgrade_banner_description": "Rezervace témat, více zpráv a emailů a větší přílohy",
"signup_title": "Vytvořit nfty účet",
"signup_form_password": "Heslo",
"display_name_dialog_description": "Nastaví alternativní jméno pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat téma s komplikovanými jmény.",
"action_bar_change_display_name": "Změnit přezdívku",
"action_bar_sign_in": "Přihlásit se",
"alert_not_supported_context_description": "Upozornění jsou podporované pouze přes HTTPS. Toto je limitace <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_title": "Změnit přezdívku"
}

View File

@ -187,5 +187,158 @@
"publish_dialog_emoji_picker_show": "Emoji wählen",
"publish_dialog_topic_reset": "Thema zurücksetzen",
"publish_dialog_attach_reset": "angehängte URL entfernen",
"publish_dialog_click_reset": "Klick-URL entfernen"
"publish_dialog_click_reset": "Klick-URL entfernen",
"account_tokens_delete_dialog_description": "Stelle vor dem Löschen eines Access-Tokens sicher, dass keine Anwendung oder Skripte dieses Token verwenden. <strong>Diese Aktion kann nicht rückgängig gemacht werden</strong>.",
"account_upgrade_dialog_cancel_warning": "Dies wird <strong>Dein Abo stornieren</strong> und Dein Konto am {{date}} herabstufen. An diesem Datum werden reservierte Themen und auch auf dem Server gecachte Nachrichten <strong>gelöscht</strong>.",
"prefs_reservations_table_everyone_read_write": "Jeder kann veröffentlichen und lesen",
"prefs_reservations_table_everyone_read_only": "Ich kann veröffentlichen und lesen, jeder kann lesen",
"prefs_reservations_table_access_header": "Zugriff",
"account_tokens_dialog_button_cancel": "Abbrechen",
"account_tokens_dialog_expires_x_hours": "Token verfällt in {{hours}} Stunden",
"account_tokens_dialog_expires_never": "Token verfällt nie",
"signup_form_username": "Benutzername",
"signup_form_button_submit": "Konto anlegen",
"signup_already_have_account": "Du hast schon ein Konto? Melde Dich an!",
"signup_disabled": "Die Anmeldung ist deaktiviert",
"login_title": "Melde Dich mit Deinem ntfy-Konto an",
"login_form_button_submit": "Anmelden",
"login_link_signup": "Konto erstellen",
"login_disabled": "Anmeldung ist deaktiviert",
"action_bar_account": "Konto",
"action_bar_change_display_name": "Anzeigenamen ändern",
"action_bar_reservation_add": "Thema reservieren",
"action_bar_reservation_edit": "Reservierung ändern",
"action_bar_reservation_delete": "Reservierung löschen",
"action_bar_reservation_limit_reached": "Grenze erreicht",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Einstellungen",
"action_bar_profile_logout": "Abmelden",
"action_bar_sign_in": "Anmelden",
"signup_form_password": "Kennwort",
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
"nav_button_account": "Konto",
"nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge",
"display_name_dialog_title": "Anzeigennamen ändern",
"display_name_dialog_placeholder": "Anzeigename",
"reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren",
"subscribe_dialog_error_topic_already_reserved": "Thema ist bereits reserviert",
"account_basics_username_title": "Benutzername",
"account_basics_username_description": "Hey, das bist Du ❤",
"account_basics_password_description": "Konto-Kennwort ändern",
"account_basics_password_dialog_title": "Kennwort ändern",
"account_basics_password_dialog_current_password_label": "Aktuelles Kennwort",
"account_basics_password_dialog_new_password_label": "Neues Kennwort",
"account_basics_password_dialog_confirm_password_label": "Kennwort bestätigen",
"account_basics_password_dialog_current_password_incorrect": "Kennwort falsch",
"account_usage_title": "Verbrauch",
"account_usage_of_limit": "von {{limit}}",
"account_usage_unlimited": "unbegrenzt",
"account_usage_limits_reset_daily": "Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt",
"account_basics_password_title": "Kennwort",
"account_basics_tier_description": "Der Funktionsumfang Deines Konto-Levels",
"account_basics_tier_admin_suffix_with_tier": "(mit Level {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(kein Level)",
"account_basics_tier_admin": "Admin",
"account_basics_tier_basic": "Basic",
"account_basics_tier_free": "Kostenlos",
"account_basics_tier_paid_until": "Abo bezahlt bis {{date}} mit automatischer Verlängerung",
"account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.",
"account_basics_tier_manage_billing_button": "Zahlung verwalten",
"account_usage_messages_title": "Veröffentlichte Nachrichten",
"account_usage_emails_title": "Gesendete Emails",
"account_usage_reservations_title": "Reservierte Themen",
"account_usage_reservations_none": "Keine reservierten Themen für dieses Konto",
"account_usage_attachment_storage_title": "Speicherplatz für Anhänge",
"account_usage_attachment_storage_description": "{{filesize}} pro Datei, Löschung nach {{expiry}}",
"account_usage_cannot_create_portal_session": "Kann Abrechnungsportal nicht öffnen",
"account_delete_title": "Konto löschen",
"account_delete_description": "Konto endgültig löschen",
"account_delete_dialog_label": "Kennwort",
"account_delete_dialog_button_cancel": "Abbrechen",
"account_delete_dialog_button_submit": "Lösche mein Konto endgültig",
"account_basics_tier_change_button": "Wechseln",
"account_basics_tier_canceled_subscription": "Dein Abo wurde storniert und wird am {{date}} auf ein kostenloses Konto herabgestuft.",
"account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.",
"account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.",
"account_upgrade_dialog_title": "Konto-Level ändern",
"account_upgrade_dialog_proration_info": "<strong>Anrechnung</strong>: Wenn Du zwischen kostenpflichtigen Leveln wechselst wir die Differenz bei der nächsten Abrechnung nachberechnet oder erstattet. Du erhältst bis zum Ende der Abrechnungsperiode keine neue Rechnung.",
"account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
"account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reservierte Themen",
"account_upgrade_dialog_tier_features_messages": "{{messages}} Nachrichten pro Tag",
"account_upgrade_dialog_tier_features_emails": "{{emails}} Emails pro Tag",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz",
"account_upgrade_dialog_tier_selected_label": "Ausgewählt",
"account_upgrade_dialog_tier_current_label": "Aktuell",
"account_upgrade_dialog_button_cancel": "Abbrechen",
"account_upgrade_dialog_button_redirect_signup": "Jetzt ein Konto anlegen",
"account_upgrade_dialog_button_pay_now": "Jetzt bezahlen und abonnieren",
"account_upgrade_dialog_button_cancel_subscription": "Abo stornieren",
"account_upgrade_dialog_button_update_subscription": "Abo aktualisieren",
"account_tokens_title": "Access-Token",
"account_tokens_description": "Verwende Access-Token zum Versenden und Empfangen über die ntfy-API, um nicht Deine Zugangsdaten verwenden zu müssen. Lies die <Link>Dokumentation</Link> für mehr Info.",
"account_tokens_table_token_header": "Token",
"account_tokens_table_label_header": "Bezeichnung",
"account_tokens_table_last_access_header": "Letzter Zugriff",
"account_tokens_table_expires_header": "Verfällt",
"account_tokens_table_never_expires": "Verfällt nie",
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
"account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren",
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
"account_tokens_table_create_token_button": "Access-Token erzeugen",
"account_tokens_table_last_origin_tooltip": "Von IP-Adresse {{ip}}, klicke zum Nachschlagen",
"account_tokens_dialog_title_create": "Access-Token erzeugen",
"account_tokens_dialog_title_edit": "Access-Token bearbeiten",
"account_tokens_dialog_title_delete": "Access-Token löschen",
"account_tokens_dialog_label": "Bezeichnung, z.B. Radarr Benachrichtigungen",
"account_tokens_dialog_button_create": "Token erzeugen",
"account_tokens_dialog_button_update": "Token aktualisieren",
"account_tokens_dialog_expires_label": "Access-Token verfällt in",
"account_tokens_dialog_expires_unchanged": "Verfallsdatum nicht ändern",
"account_tokens_dialog_expires_x_days": "Token verfällt in {{days}} Tagen",
"account_tokens_delete_dialog_title": "Access-Token löschen",
"account_tokens_delete_dialog_submit_button": "Token endgültig löschen",
"prefs_users_description_no_sync": "Benutzernamen und Kennwörter werden nicht im Konto synchronisiert.",
"prefs_users_table_cannot_delete_or_edit": "Angemeldeter Benutzer kann nicht gelöscht oder bearbeitet werden",
"prefs_reservations_title": "Reservierte Themen",
"prefs_reservations_description": "Du kannst hier Themen-Namen für Deine persönliche Verwendung reservieren. Das Reservieren eines Themas macht Dich zum Besitzer des Themas. Du kannst damit auch Zugriffsrechte für andere Benutzer auf das Thema festlegen.",
"prefs_reservations_limit_reached": "Du hast Dein Limit an reservierten Themen erreicht.",
"prefs_reservations_add_button": "Reserviertes Thema hinzufügen",
"prefs_reservations_edit_button": "Zugriff auf Thema bearbeiten",
"prefs_reservations_delete_button": "Zugriff auf Thema zurücksetzen",
"prefs_reservations_table": "Übersicht reservierter Themen",
"prefs_reservations_table_topic_header": "Thema",
"prefs_reservations_table_everyone_deny_all": "Nur kann veröffentlichen und lesen",
"prefs_reservations_table_everyone_write_only": "Ich kann veröffentlichen und lesen, jeder kann veröffentlichen",
"prefs_reservations_table_not_subscribed": "Nicht abonniert",
"prefs_reservations_table_click_to_subscribe": "Klicken um zu abonnieren",
"prefs_reservations_dialog_title_add": "Thema reservieren",
"prefs_reservations_dialog_title_edit": "Reserviertes Thema bearbeiten",
"prefs_reservations_dialog_title_delete": "Thema-Reservierung löschen",
"prefs_reservations_dialog_description": "Ein Thema zu reservieren macht Dich zum Besitzer des Themas, und erlaubt Dir Zugriffsrechte für andere auf dieses Thema festzulegen.",
"prefs_reservations_dialog_topic_label": "Thema",
"prefs_reservations_dialog_access_label": "Zugriff",
"reservation_delete_dialog_description": "Mit dem Löschen einer Reservierung gibst du den Besitz des Themas auf und ermöglichst anderen, es zu reservieren. Du kannst vorhandene Nachrichten und Dateien behalten oder löschen.",
"reservation_delete_dialog_action_keep_title": "Behalte gecachte Nachrichten und Dateien",
"reservation_delete_dialog_action_keep_description": "Nachrichten und Dateien, die auf dem Server gecached sind, werden für alle sichtbar die den Themen-Namen kennen.",
"reservation_delete_dialog_action_delete_title": "Löschen gecachte Nachrichten und Dateien",
"reservation_delete_dialog_action_delete_description": "Gecachte Nachrichten und Dateien werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"reservation_delete_dialog_submit_button": "Reservierung löschen",
"account_basics_password_dialog_button_submit": "Kennwort ändern",
"account_basics_tier_title": "Kontotyp",
"account_basics_tier_upgrade_button": "Upgrade auf Pro",
"account_delete_dialog_description": "Hiermit wird Dein Konto endgültig gelöscht, inklusive aller Daten auf dem Server. Nach dem Löschen wird Dein Benutzername für 7 Tage gesperrt sein. Wenn Du fortfahren willst, bestätige das durch Eingabe Deines Kennwortes.",
"signup_form_confirm_password": "Kennwort wiederholen",
"signup_title": "Erstelle ein ntfy-Konto",
"signup_error_username_taken": "Benutzername {{username}} ist bereits vergeben",
"signup_error_creation_limit_reached": "Grenze der Account-Erstellung erreicht",
"subscribe_dialog_subscribe_button_generate_topic_name": "Namen erzeugen",
"account_basics_title": "Konto",
"action_bar_sign_up": "Konto erstellen",
"nav_upgrade_banner_label": "Upgrade auf ntfy Pro",
"alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.",
"account_basics_username_admin_tooltip": "Du bist Admin"
}

View File

@ -193,6 +193,8 @@
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
"account_basics_tier_basic": "Basic",
"account_basics_tier_free": "Free",
"account_basics_tier_interval_monthly": "monthly",
"account_basics_tier_interval_yearly": "annually",
"account_basics_tier_upgrade_button": "Upgrade to Pro",
"account_basics_tier_change_button": "Change",
"account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
@ -215,15 +217,23 @@
"account_delete_dialog_button_submit": "Permanently delete account",
"account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
"account_upgrade_dialog_title": "Change account tier",
"account_upgrade_dialog_interval_monthly": "Monthly",
"account_upgrade_dialog_interval_yearly": "Annually",
"account_upgrade_dialog_interval_yearly_discount_save": "save {{discount}}%",
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "save up to {{discount}}%",
"account_upgrade_dialog_cancel_warning": "This will <strong>cancel your subscription</strong>, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server <strong>will be deleted</strong>.",
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: When switching between paid plans, the price difference will be charged or refunded in the next invoice. You will not receive another invoice until the end of the next billing period.",
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: When upgrading between paid plans, the price difference will be <strong>charged immediately</strong>. When downgrading to a lower tier, the balance will be used to pay for future billing periods.",
"account_upgrade_dialog_reservations_warning_one": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least one reservation</strong>. You can remove reservations in the <Link>Settings</Link>.",
"account_upgrade_dialog_reservations_warning_other": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least {{count}} reservations</strong>. You can remove reservations in the <Link>Settings</Link>.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserved topics",
"account_upgrade_dialog_tier_features_no_reservations": "No reserved topics",
"account_upgrade_dialog_tier_features_messages": "{{messages}} daily messages",
"account_upgrade_dialog_tier_features_emails": "{{emails}} daily emails",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
"account_upgrade_dialog_tier_price_per_month": "month",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
"account_upgrade_dialog_tier_selected_label": "Selected",
"account_upgrade_dialog_tier_current_label": "Current",
"account_upgrade_dialog_button_cancel": "Cancel",

View File

@ -187,5 +187,33 @@
"prefs_users_table": "Tabla de usuarios",
"prefs_users_edit_button": "Editar usuario",
"prefs_users_delete_button": "Eliminar usuario",
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada"
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada",
"action_bar_profile_title": "Perfil",
"action_bar_profile_settings": "Configuración",
"signup_title": "Crear una cuenta ntfy",
"signup_form_username": "Nombre de usuario",
"signup_form_password": "Contraseña",
"signup_form_confirm_password": "Confirmar contraseña",
"signup_form_button_submit": "Registro",
"signup_form_toggle_password_visibility": "Alternar la visibilidad de la contraseña",
"signup_already_have_account": "¿Ya tienes una cuenta? ¡Iniciar sesión!",
"signup_disabled": "El registro está deshabilitado",
"signup_error_username_taken": "El nombre de usuario {{username}} ya está en uso",
"signup_error_creation_limit_reached": "Límite de creación de cuenta alcanzado",
"login_title": "Inicie sesión en su cuenta ntfy",
"login_form_button_submit": "Iniciar sesión",
"login_link_signup": "Registro",
"login_disabled": "Inicio de sesión deshabilitado",
"action_bar_account": "Cuenta",
"action_bar_change_display_name": "Cambiar nombre de usuario",
"action_bar_reservation_add": "Reservar tema",
"action_bar_reservation_edit": "Modificar reserva",
"action_bar_reservation_delete": "Quitar reserva",
"action_bar_reservation_limit_reached": "Límite alcanzado",
"action_bar_profile_logout": "Cerrar sesión",
"action_bar_sign_in": "Iniciar sesión",
"action_bar_sign_up": "Registro",
"nav_button_account": "Cuenta",
"nav_upgrade_banner_label": "Actualizar a ntfy Pro",
"nav_upgrade_banner_description": "Reserve temas, más mensajes y correos electrónicos, y archivos adjuntos más grandes"
}

View File

@ -187,5 +187,25 @@
"prefs_users_edit_button": "Éditer l'utilisateur",
"prefs_users_delete_button": "Supprimer l'utilisateur",
"error_boundary_unsupported_indexeddb_title": "Navigation privée non prise en charge",
"publish_dialog_attached_file_remove": "Retirer le fichier joint"
"publish_dialog_attached_file_remove": "Retirer le fichier joint",
"signup_form_password": "Mot de passe",
"signup_form_confirm_password": "Mot de passe (confirmation)",
"signup_disabled": "Inscriptions désactivées",
"signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé",
"signup_error_creation_limit_reached": "Limite de comptes atteinte",
"login_title": "Se connecter à son compte Ntfy",
"login_form_button_submit": "Connexion",
"login_link_signup": "S'enregistrer",
"login_disabled": "Identification désactivée",
"action_bar_account": "Compte",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Paramètres",
"action_bar_sign_in": "Connexion",
"action_bar_sign_up": "Inscription",
"nav_button_account": "Compte",
"signup_title": "Créer un compte Ntfy",
"signup_form_username": "Identifiant",
"signup_form_button_submit": "S'inscrire",
"signup_already_have_account": "Vous avez déjà un compte, connectez-vous.",
"action_bar_profile_logout": "Se déconnecter"
}

View File

@ -187,5 +187,158 @@
"prefs_users_edit_button": "Edit pengguna",
"prefs_users_delete_button": "Hapus pengguna",
"error_boundary_unsupported_indexeddb_description": "Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.<br/><br/>Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya <githubLink>di masalah GitHub ini</githubLink>, atau berbicara dengan kami di <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
"error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung"
"error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung",
"signup_form_confirm_password": "Konfirmasi kata sandi",
"signup_form_button_submit": "Daftar",
"signup_form_toggle_password_visibility": "Alih keterlihatan kata sandi",
"signup_already_have_account": "Sudah punya akun? Masuk!",
"signup_disabled": "Pendaftaran dinonaktifkan",
"signup_error_username_taken": "Nama pengguna {{username}} telah digunakan",
"signup_error_creation_limit_reached": "Batasan pembuatan akun tercapai",
"login_title": "Masuk ke akun ntfy Anda",
"login_disabled": "Pemasukan dinonaktifkan",
"action_bar_account": "Akun",
"action_bar_change_display_name": "Ubah nama tampilan",
"action_bar_reservation_add": "Reservasi topik",
"action_bar_reservation_edit": "Ubah reservasi",
"action_bar_reservation_delete": "Hapus reservasi",
"action_bar_reservation_limit_reached": "Batasan tercapai",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Pengaturan",
"action_bar_profile_logout": "Keluar",
"nav_button_account": "Akun",
"display_name_dialog_placeholder": "Nama tampilan",
"reserve_dialog_checkbox_label": "Reservasi topik dan atur akses",
"nav_upgrade_banner_description": "Reservasikan topik, lebih banyak pesan & surel, dan lampiran lebih besar",
"signup_title": "Buat sebuah akun ntfy",
"signup_form_password": "Kata sandi",
"login_link_signup": "Daftar",
"action_bar_sign_up": "Daftar",
"signup_form_username": "Nama pengguna",
"login_form_button_submit": "Masuk",
"action_bar_sign_in": "Masuk",
"nav_upgrade_banner_label": "Tingkatkan ke ntfy Pro",
"alert_not_supported_context_description": "Notifikasi hanya didukung melalui HTTPS. Ini adalah batasan <mdnLink>API Notifikasi</mdnLink>.",
"display_name_dialog_title": "Ubah nama tampilan",
"display_name_dialog_description": "Tetapkan nama alternatif untuk sebuah topik yang ditampilkan di daftar langganan. Ini membantu mengidentifikasi topik dengan nama yang rumit dengan lebih mudah.",
"subscribe_dialog_error_topic_already_reserved": "Topik sudah direservasi",
"account_basics_username_title": "Nama pengguna",
"account_basics_username_admin_tooltip": "Anda adalah Admin",
"account_basics_password_title": "Kata sandi",
"account_basics_password_description": "Ubah kata sandi akun Anda",
"account_basics_password_dialog_title": "Ubah kata sandi",
"account_basics_password_dialog_current_password_label": "Kata sandi saat ini",
"account_basics_password_dialog_confirm_password_label": "Konfirmasi kata sandi",
"account_basics_password_dialog_button_submit": "Ubah kata sandi",
"account_basics_password_dialog_current_password_incorrect": "Kata sandi salah",
"account_usage_title": "Penggunaan",
"account_usage_of_limit": "dari {{limit}}",
"account_usage_unlimited": "Tidak terbatas",
"account_usage_limits_reset_daily": "Batasan penggunaan diatur ulang setiap hari di tengah malam (UTC)",
"account_basics_tier_title": "Jenis akun",
"account_basics_tier_description": "Tingkat daya akun Anda",
"account_basics_tier_admin_suffix_no_tier": "(tidak ada peringkat)",
"account_basics_tier_basic": "Dasaran",
"account_basics_tier_change_button": "Ubah",
"account_basics_tier_paid_until": "Langganan dibayar sampai {{date}}, dan akan dibayar secara otomatis",
"account_basics_tier_canceled_subscription": "Langganan Anda dibatalkan dan akan diturunkan ke akun gratis pada {{date}}.",
"account_usage_messages_title": "Pesan terkirim",
"account_usage_emails_title": "Surel terkirim",
"account_usage_reservations_title": "Topik yang telah direservasi",
"account_usage_reservations_none": "Tidak ada topik yang telah direservasi untuk akun ini",
"account_usage_attachment_storage_title": "Penyimpanan lampiran",
"account_usage_attachment_storage_description": "{{filesize}} per berkas, dihapus setelah {{expiry}}",
"account_delete_title": "Hapus akun",
"account_delete_description": "Hapus akun Anda secara permanen",
"account_delete_dialog_label": "Kata sandi",
"account_delete_dialog_button_cancel": "Batal",
"account_delete_dialog_button_submit": "Hapus akun secara permanen",
"account_usage_cannot_create_portal_session": "Tidak dapat membuka portal tagihan",
"account_delete_dialog_billing_warning": "Menghapus akun Anda juga membatalkan tagihan langganan dengan segera. Anda tidak akan memiliki akses lagi ke dasbor tagihan.",
"account_upgrade_dialog_title": "Ubah peringkat akun",
"account_upgrade_dialog_proration_info": "<strong>Prorasi</strong>: Ketika mengubah rencana berbayar, perubahan harga akan ditagih atau dikembalikan di faktur berikutnya. Anda tidak akan menerima faktur lain sampai akhir periode tagihan.",
"account_upgrade_dialog_reservations_warning_other": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, <strong>silakan menghapus setidaknya {{count}} reservasi</strong>. Anda dapat menghapus reservasi di <Link>Pengaturan</Link>.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} topik yang telah direservasi",
"account_upgrade_dialog_tier_features_messages": "{{messages}} pesan harian",
"account_upgrade_dialog_tier_features_emails": "{{emails}} surel harian",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per berkas",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} jumlah penyimpanan",
"account_upgrade_dialog_tier_selected_label": "Dipilih",
"account_upgrade_dialog_tier_current_label": "Saat ini",
"account_upgrade_dialog_button_cancel": "Batal",
"account_upgrade_dialog_button_redirect_signup": "Daftar sekarang",
"account_upgrade_dialog_button_pay_now": "Bayar sekaramg dan berlangganan",
"account_upgrade_dialog_button_cancel_subscription": "Batalkan langganan",
"account_upgrade_dialog_button_update_subscription": "Perbarui langganan",
"account_tokens_title": "Token akses",
"account_tokens_description": "Gunakan token akses saat mengirim dan berlangganan melalui API ntfy, sehingga Anda tidak perlu mengirimkan kredensial akun Anda. Lihat <Link>dokumentasi</Link> untuk mempelajari lebih lanjut.",
"account_tokens_table_token_header": "Token",
"account_tokens_table_label_header": "Label",
"account_tokens_table_last_access_header": "Akses terakhir",
"account_tokens_table_expires_header": "Kedaluwarsa",
"account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
"account_tokens_table_current_session": "Sesi peramban saat ini",
"account_tokens_table_copy_to_clipboard": "Salin ke papan klip",
"account_tokens_table_copied_to_clipboard": "Token akses disalin",
"account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
"account_tokens_table_create_token_button": "Buat token akses",
"account_tokens_dialog_expires_unchanged": "Tinggalkan tanggal kedaluwarsa tidak terganti",
"account_tokens_dialog_expires_x_hours": "Token kedaluwarsa dalam {{hours}} jam",
"account_tokens_dialog_expires_x_days": "Token kedaluwarsa dalam {{days}} hari",
"account_tokens_dialog_expires_never": "Token tidak pernah kedaluwarsa",
"account_tokens_delete_dialog_title": "Hapus token akses",
"account_tokens_delete_dialog_description": "Sebelum menghapus sebuah token akses, pastikan bahwa tidak ada aplikasi atau skrip yang sedang menggunakannya secara aktif. <strong>Tindakan ini tidak dapat diurungkan</strong>.",
"account_tokens_delete_dialog_submit_button": "Hapus token secara permanan",
"prefs_reservations_title": "Topik yang direservasi",
"reservation_delete_dialog_action_keep_title": "Jaga tembolok pesan dan lampiran",
"reservation_delete_dialog_action_keep_description": "Tembolok pesan dan lampiran yang berada di server akan terlihat secara publik untuk orang-orang dengan pengetahuan nama topik.",
"reservation_delete_dialog_action_delete_title": "Hapus tembolok pesan dan lampiran",
"reservation_delete_dialog_action_delete_description": "Tembolok pesan dan lampiran akan dihapus secara permanen. Tindakan ini tidak dapat diurungkan.",
"reservation_delete_dialog_submit_button": "Hapus reservasi",
"prefs_reservations_table_everyone_read_only": "Saya dapat mengirim dan berlangganan, semuanya dapat berlangganan",
"prefs_reservations_dialog_title_edit": "Sunting reservasi topik",
"subscribe_dialog_subscribe_button_generate_topic_name": "Buat nama",
"account_basics_title": "Akun",
"account_basics_tier_admin_suffix_with_tier": "(dengan peringkat {{tier}})",
"account_basics_tier_free": "Gratis",
"account_tokens_dialog_expires_label": "Token akses kedaluwarsa dalam",
"account_basics_username_description": "Hei, itu Anda ❤",
"account_basics_password_dialog_new_password_label": "Kata sandi baru",
"account_basics_tier_admin": "Admin",
"account_basics_tier_upgrade_button": "Tingkatkan ke Pro",
"account_basics_tier_payment_overdue": "Pembayaran Anda telah jatuh tempo. Mohon perbarui metode pembayaran Anda, atau akun Anda akan segera diturunkan.",
"account_basics_tier_manage_billing_button": "Kelola pembayaran",
"account_tokens_dialog_title_delete": "Hapus token akses",
"account_usage_basis_ip_description": "Statistik dan batasan pengguna untuk akun ini berdasarkan alamat IP Anda, sehingga mereka mungkin terbagi dengan pengguna lain. Batasan yang ditampilkan di atas adalah perkiraan berdasarkan batas tarif yang sudah ada.",
"account_delete_dialog_description": "Ini akan menghapus akun Anda secara permanen, termasuk semua data yang telah disimpan di server ini. Setelah penghapusan, nama pengguna Anda akan tidak tersedia selama 7 hari. Jika Anda ingin melanjutkan, silakan mengonfirmasi dengan kata sandi Anda di kotak bawah.",
"account_upgrade_dialog_cancel_warning": "Ini akan <strong>membatalkan langganan Anda</strong>, dan menurunkan akun Anda pada tanggal {{date}}. Pada tanggal itu, reservasi topik maupun tembolok pesan di server <strong>akan dihapus</strong>.",
"prefs_reservations_table_everyone_write_only": "Saya dapat mengirim dan berlangganan, semuanya dapat mengirim",
"account_tokens_table_last_origin_tooltip": "Dari alamat IP {{ip}}, klik untuk melihat",
"account_tokens_dialog_label": "Label, mis. notifikasi Radarr",
"account_tokens_dialog_button_create": "Buat token",
"prefs_reservations_description": "Anda dapat mereservasi nama topik untuk penggunaan pribadi di sini. Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.",
"account_upgrade_dialog_reservations_warning_one": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, <strong>silakan menghapus setidaknya satu reservasi</strong>. Anda dapat menghapus reservasi di <Link>Pengaturan</Link>.",
"account_tokens_dialog_button_cancel": "Batal",
"account_tokens_dialog_title_create": "Buat token akses",
"account_tokens_dialog_title_edit": "Sunting token akses",
"account_tokens_dialog_button_update": "Perbarui token",
"prefs_reservations_add_button": "Tambahkan reservasi topik",
"prefs_reservations_table": "Tabel topik yang telah direservasi",
"prefs_reservations_table_topic_header": "Topik",
"prefs_users_table_cannot_delete_or_edit": "Tidak dapat menghapus atau menyunting pengguna yang telah masuk",
"prefs_reservations_table_everyone_deny_all": "Hanya saya yang dapat mengirim dan berlangganan",
"prefs_reservations_table_everyone_read_write": "Semuanya dapat mengirim dan berlangganan",
"prefs_users_description_no_sync": "Pengguna dan kata sandi tidak disinkronkan ke akun Anda.",
"prefs_reservations_limit_reached": "Anda telah mencapai batasan reservasi topik.",
"prefs_reservations_edit_button": "Sunting akses topik",
"prefs_reservations_table_click_to_subscribe": "Klik untuk berlangganan",
"prefs_reservations_delete_button": "Atur ulang akses topik",
"prefs_reservations_table_access_header": "Akses",
"prefs_reservations_dialog_title_add": "Reservasi topik",
"prefs_reservations_dialog_title_delete": "Hapus reservasi topik",
"prefs_reservations_table_not_subscribed": "Tidak berlangganan",
"prefs_reservations_dialog_description": "Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.",
"prefs_reservations_dialog_topic_label": "Topik",
"prefs_reservations_dialog_access_label": "Akses",
"reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya."
}

View File

@ -187,5 +187,37 @@
"prefs_notifications_sound_play": "選択されたサウンドを再生",
"prefs_users_table": "ユーザー一覧",
"prefs_users_delete_button": "ユーザーを削除",
"error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません"
"error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません",
"signup_form_username": "ユーザー名",
"signup_form_password": "パスワード",
"signup_form_confirm_password": "パスワードを確認",
"signup_already_have_account": "アカウントをお持ちならサインイン",
"signup_disabled": "サインアップは無効化されています",
"signup_error_creation_limit_reached": "アカウント作成制限に達しました",
"login_title": "あなたのntfyアカウントにサインイン",
"login_link_signup": "サインアップ",
"login_disabled": "ログインは無効化されています",
"action_bar_account": "アカウント",
"action_bar_change_display_name": "表示名を変更する",
"action_bar_reservation_add": "トピックを予約する",
"action_bar_reservation_edit": "予約を編集する",
"action_bar_reservation_limit_reached": "制限に達しました",
"action_bar_profile_title": "プロファイル",
"action_bar_profile_settings": "設定",
"action_bar_profile_logout": "ログアウト",
"action_bar_sign_in": "サインイン",
"action_bar_sign_up": "サインアップ",
"nav_button_account": "アカウント",
"nav_upgrade_banner_label": "ntfy Proにアップグレード",
"display_name_dialog_title": "表示名を変更",
"display_name_dialog_placeholder": "表示名",
"signup_form_button_submit": "サインアップ",
"signup_form_toggle_password_visibility": "パスワードを表示/非表示",
"signup_title": "ntfyアカウントを作成する",
"login_form_button_submit": "サインイン",
"alert_not_supported_context_description": "通知はHTTPSのみサポートされています。これは<mdnLink>Notifications API</mdnLink>の制限によるものです。",
"nav_upgrade_banner_description": "トピックを予約、より多くのメッセージとメール、より大きい添付ファイル",
"signup_error_username_taken": "ユーザー名 {{username}} は既に使用されています",
"action_bar_reservation_delete": "予約を削除する",
"display_name_dialog_description": "購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。"
}

View File

@ -187,5 +187,8 @@
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
"prefs_users_table": "Brukertabell",
"prefs_users_edit_button": "Rediger bruker",
"error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke"
"error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke",
"action_bar_account": "Konto",
"action_bar_profile_settings": "Innstillinger",
"nav_button_account": "Konto"
}

View File

@ -1,6 +1,6 @@
{
"action_bar_settings": "Instellingen",
"action_bar_send_test_notification": "Verstuur testnotificatie.",
"action_bar_send_test_notification": "Verstuur testnotificatie",
"action_bar_clear_notifications": "Wis alle notificaties",
"message_bar_type_message": "Typ hier een bericht",
"action_bar_unsubscribe": "Afmelden",
@ -187,5 +187,66 @@
"priority_default": "standaard",
"priority_high": "hoog",
"priority_max": "max",
"error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund"
"error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund",
"signup_form_username": "Gebruikersnaam",
"signup_form_toggle_password_visibility": "Wachtwoord zichtbaar maken",
"signup_already_have_account": "Heb je al een account? Log in!",
"signup_form_button_submit": "Registreer",
"signup_disabled": "Registreren is uitgeschakeld",
"signup_error_username_taken": "Gebruikersnaam {{username}} is al bezet",
"signup_error_creation_limit_reached": "Limiet voor aanmaken account bereikt",
"login_title": "Aanmelden bij uw ntfy account",
"login_form_button_submit": "Inloggen",
"login_link_signup": "Registreer",
"login_disabled": "Inloggen is uitgeschakeld",
"action_bar_account": "Account",
"action_bar_reservation_add": "Onderwerp reserveren",
"action_bar_reservation_edit": "Reservatie wijzigen",
"action_bar_reservation_delete": "Verwijder reservatie",
"action_bar_reservation_limit_reached": "Limiet bereikt",
"action_bar_profile_title": "Profiel",
"nav_upgrade_banner_label": "Upgrade naar ntfy Pro",
"nav_upgrade_banner_description": "Onderwerpen reserveren, meer berichten & e-mails, en grotere bijlagen",
"alert_not_supported_context_description": "Notificaties worden alleen ondersteund via HTTPS. Dit is een beperking van de <mdnLink>Notificaties API</mdnLink>.",
"display_name_dialog_placeholder": "Weergavenaam",
"reserve_dialog_checkbox_label": "Onderwerp reserveren en toegang configureren",
"account_basics_title": "Account",
"account_basics_username_title": "Gebruikersnaam",
"account_basics_username_description": "Hé, dat ben jij ❤",
"account_basics_username_admin_tooltip": "Je bent beheerder",
"account_basics_password_title": "Wachtwoord",
"account_basics_password_description": "Wijzig het wachtwoord van je account",
"account_basics_password_dialog_current_password_label": "Huidig wachtwoord",
"account_basics_password_dialog_new_password_label": "Nieuw wachtwoord",
"account_basics_password_dialog_confirm_password_label": "Bevestig wachtwoord",
"account_basics_password_dialog_button_submit": "Wijzig wachtwoord",
"account_basics_password_dialog_current_password_incorrect": "Wachtwoord onjuist",
"account_usage_title": "Gebruik",
"account_usage_of_limit": "van {{limit}}",
"account_usage_unlimited": "Onbeperkt",
"account_basics_tier_title": "Account type",
"account_basics_tier_admin": "Beheerder",
"account_basics_tier_admin_suffix_with_tier": "",
"account_basics_tier_basic": "Basis",
"account_basics_tier_free": "Gratis",
"account_basics_tier_change_button": "Wijzig",
"account_basics_tier_paid_until": "Abonnement betaald tot {{date}}, en wordt automatisch verlengd",
"account_basics_tier_payment_overdue": "Je betaling is te laat. Update je betalingsmethode, anders wordt je account binnenkort gedowngraded.",
"account_basics_tier_canceled_subscription": "Je abonnement is opgezegd en wordt op {{date}} gedowngraded naar een gratis account.",
"signup_form_password": "Wachtwoord",
"signup_title": "Een ntfy account aanmaken",
"signup_form_confirm_password": "Bevestig wachtwoord",
"action_bar_change_display_name": "Weergavenaam wijzigen",
"action_bar_profile_logout": "Uitloggen",
"action_bar_profile_settings": "Instellingen",
"action_bar_sign_up": "Registreer",
"nav_button_account": "Account",
"action_bar_sign_in": "Inloggen",
"display_name_dialog_title": "Weergavenaam wijzigen",
"display_name_dialog_description": "Stel een alternatieve naam in voor een onderwerp dat wordt weergeven in de abonnementenlijst. Dit helpt onderwerpen met gecompliceerde namen gemakkelijker te identificeren.",
"subscribe_dialog_subscribe_button_generate_topic_name": "Naam genereren",
"subscribe_dialog_error_topic_already_reserved": "Onderwerp al gereserveerd",
"account_basics_password_dialog_title": "Wijzig wachtwoord",
"account_usage_limits_reset_daily": "Gebruikslimieten worden dagelijks om middernacht (UTC) gereset",
"account_basics_tier_upgrade_button": "Upgrade naar Pro"
}

View File

@ -187,5 +187,32 @@
"priority_high": "alta",
"priority_max": "máxima",
"error_boundary_title": "Oh não, o ntfy parou de funcionar",
"error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")"
"error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")",
"signup_title": "Criar uma conta ntfy",
"signup_form_username": "Nome de utilizador",
"signup_form_confirm_password": "Confirmar palavra-passe",
"signup_form_button_submit": "Registar",
"signup_form_toggle_password_visibility": "Alternar visibilidade da palavra-passe",
"signup_already_have_account": "Já tem uma conta? Inicie sessão!",
"signup_disabled": "Novos registos desativados",
"signup_error_username_taken": "O nome \"{{username}}\" já está em uso",
"signup_error_creation_limit_reached": "Limite de criação de contas atingido",
"login_title": "Inicie sessão na sua conta ntfy",
"login_form_button_submit": "Iniciar sessão",
"login_disabled": "Início de sessão desativado",
"action_bar_account": "Conta",
"action_bar_change_display_name": "Alterar nome de exibição",
"action_bar_reservation_delete": "Remover reserva",
"action_bar_reservation_limit_reached": "Limite alcançado",
"action_bar_profile_title": "Perfil",
"action_bar_profile_settings": "Configurações",
"action_bar_profile_logout": "Terminar sessão",
"action_bar_sign_in": "Iniciar sessão",
"nav_upgrade_banner_description": "Reserve tópicos, envie mais mensagens, emails e anexos maiores",
"signup_form_password": "Palavra-passe",
"action_bar_reservation_edit": "Alterar reserva",
"login_link_signup": "Registar",
"action_bar_reservation_add": "Reservar tópico",
"action_bar_sign_up": "Registar",
"nav_button_account": "Conta"
}

View File

@ -0,0 +1,11 @@
{
"action_bar_show_menu": "Afișează meniu",
"action_bar_send_test_notification": "Trimite notificare de probă",
"action_bar_clear_notifications": "Șterge toate notificările",
"action_bar_settings": "Setări",
"action_bar_unsubscribe": "Dezabonare",
"action_bar_logo_alt": "logo-ul ntfy",
"action_bar_toggle_mute": "Oprire/activare notificări",
"message_bar_type_message": "Scrie un mesaj aici",
"message_bar_error_publishing": "Eroare la publicarea notificării"
}

View File

@ -187,5 +187,158 @@
"notifications_priority_x": "Öncelik {{priority}}",
"publish_dialog_email_reset": "E-posta yönlendirmesini kaldır",
"prefs_users_edit_button": "Kullanıcıyı düzenle",
"prefs_users_delete_button": "Kullanıcı sil"
"prefs_users_delete_button": "Kullanıcı sil",
"signup_form_confirm_password": "Parolayı doğrula",
"signup_form_button_submit": "Kaydol",
"signup_form_toggle_password_visibility": "Parola görünürlüğünü değiştir",
"signup_already_have_account": "Zaten hesabınız var mı? Oturum açın!",
"signup_disabled": "Kayıt devre dışı bırakıldı",
"signup_error_username_taken": "{{username}} kullanıcı adı zaten alındı",
"signup_error_creation_limit_reached": "Hesap oluşturma sınırına ulaşıldı",
"login_title": "ntfy hesabınızda oturum açın",
"login_form_button_submit": "Oturum aç",
"login_link_signup": "Kaydol",
"login_disabled": "Oturum açma devre dışı bırakıldı",
"action_bar_account": "Hesap",
"action_bar_change_display_name": "Görünen adı değiştir",
"action_bar_reservation_add": "Konuyu ayırt",
"action_bar_reservation_edit": "Ayırtmayı değiştir",
"action_bar_reservation_delete": "Ayırtmayı kaldır",
"action_bar_reservation_limit_reached": "Sınıra ulaşıldı",
"action_bar_sign_in": "Oturum aç",
"action_bar_sign_up": "Kaydol",
"nav_button_account": "Hesap",
"nav_upgrade_banner_label": "ntfy Pro'ya yükselt",
"alert_not_supported_context_description": "Bildirimler yalnızca HTTPS üzerinden desteklenir. Bu, <mdnLink>Bildirim API'sinin</mdnLink> bir sınırlamasıdır.",
"display_name_dialog_description": "Abonelik listesinde görüntülenen bir konu için farklı bir ad belirleyin. Bu, karmaşık adlara sahip konuların daha kolay tanınmasına yardımcı olur.",
"display_name_dialog_placeholder": "Görünen ad",
"reserve_dialog_checkbox_label": "Konuyu ayırt ve erişimi yapılandır",
"subscribe_dialog_error_topic_already_reserved": "Konu zaten ayırtıldı",
"account_basics_title": "Hesap",
"account_basics_username_title": "Kullanıcı adı",
"account_basics_username_description": "Hey, bu sizsiniz ❤",
"account_basics_username_admin_tooltip": "Siz Yöneticisiniz",
"account_basics_password_title": "Parola",
"account_basics_password_description": "Hesap parolanızı değiştirin",
"account_basics_password_dialog_current_password_label": "Geçerli parola",
"account_basics_password_dialog_title": "Parolayı değiştir",
"account_basics_password_dialog_button_submit": "Parolayı değiştir",
"account_basics_password_dialog_current_password_incorrect": "Parola yanlış",
"account_usage_title": "Kullanım",
"account_usage_of_limit": "/ {{limit}}",
"account_usage_unlimited": "Sınırsız",
"account_usage_limits_reset_daily": "Kullanım sınırları her gün gece yarısında (UTC) sıfırlanır",
"account_basics_tier_title": "Hesap türü",
"account_basics_tier_description": "Hesabınızın güç seviyesi",
"account_basics_tier_admin": "Yönetici",
"account_basics_tier_basic": "Temel",
"account_basics_tier_free": "Ücretsiz",
"account_basics_tier_upgrade_button": "Pro'ya yükselt",
"account_basics_tier_change_button": "Değiştir",
"account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek",
"account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)",
"account_basics_tier_admin_suffix_no_tier": "(seviye yok)",
"account_basics_tier_manage_billing_button": "Faturalandırmayı yönet",
"account_usage_reservations_title": "Ayırtılan konular",
"account_usage_reservations_none": "Bu hesap için ayırtılan konu yok",
"account_usage_attachment_storage_title": "Ek depolama",
"account_usage_attachment_storage_description": "Dosya başına {{filesize}}, {{expiry}} sonrasında silinir",
"account_usage_cannot_create_portal_session": "Faturalandırma sayfasıılamıyor",
"account_delete_title": "Hesabı sil",
"account_delete_description": "Hesabınızı kalıcı olarak silin",
"account_delete_dialog_description": "Bu işlem, sunucuda depolanan tüm veriler dahil olmak üzere hesabınızı kalıcı olarak silecektir. Silme işleminden sonra kullanıcı adınız 7 gün boyunca kullanılamayacaktır. Gerçekten devam etmek istiyorsanız, lütfen aşağıdaki kutuya parolanızı yazarak onaylayın.",
"account_delete_dialog_button_cancel": "İptal",
"account_delete_dialog_button_submit": "Hesabı kalıcı olarak sil",
"account_delete_dialog_billing_warning": "Hesabınızı silmek, faturalandırma aboneliğinizi de anında iptal eder. Artık faturalandırma sayfasına erişiminiz olmayacak.",
"account_upgrade_dialog_title": "Hesap seviyesini değiştir",
"account_upgrade_dialog_proration_info": "<strong>Ödeme oranı</strong>: Ücretli planlar arasında geçiş yaparken, fiyat farkı bir sonraki faturada tahsil edilecek veya iade edilecektir. Bir sonraki fatura döneminin sonuna kadar başka bir fatura almayacaksınız.",
"account_upgrade_dialog_reservations_warning_other": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce <strong>lütfen en az {{count}} ayırtmayı silin</strong>. Ayırtmaları <Link>Ayarlar</Link> sayfasından kaldırabilirsiniz.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} konu ayırtıldı",
"account_upgrade_dialog_tier_features_messages": "{{messages}} günlük mesaj",
"account_upgrade_dialog_tier_features_emails": "{{emails}} günlük e-posta",
"account_upgrade_dialog_tier_features_attachment_file_size": "dosya başına {{filesize}}",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} toplam depolama",
"account_upgrade_dialog_tier_selected_label": "Seçilen",
"account_upgrade_dialog_tier_current_label": "Geçerli",
"account_upgrade_dialog_button_cancel": "İptal",
"account_upgrade_dialog_button_redirect_signup": "Şimdi kaydol",
"account_upgrade_dialog_button_pay_now": "Şimdi öde ve abone ol",
"account_upgrade_dialog_button_cancel_subscription": "Aboneliği iptal et",
"account_tokens_title": "Erişim belirteçleri",
"account_tokens_table_token_header": "Belirteç",
"account_tokens_table_label_header": "Etiket",
"account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
"account_tokens_table_copy_to_clipboard": "Panoya kopyala",
"account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
"account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
"account_tokens_table_create_token_button": "Erişim belirteci oluştur",
"account_tokens_table_last_origin_tooltip": "{{ip}} IP adresinden, aramak için tıklayın",
"account_tokens_dialog_title_edit": "Erişim belirtecini düzenle",
"account_tokens_table_expires_header": "Süre dolumu",
"account_tokens_table_never_expires": "Asla süresi dolmaz",
"account_tokens_dialog_title_delete": "Erişim belirtecini sil",
"account_tokens_dialog_label": "Etiket, örn. Radarr bildirimleri",
"account_tokens_dialog_button_create": "Belirteç oluştur",
"account_tokens_dialog_button_update": "Belirteci güncelle",
"account_tokens_dialog_button_cancel": "İptal",
"account_tokens_dialog_expires_label": "Erişim belirtecinin süre dolumu",
"account_tokens_dialog_expires_unchanged": "Süre dolumu tarihini değiştirmeden bırak",
"account_tokens_dialog_expires_x_hours": "Belirtecin süresi {{hours}} saat içinde dolacak",
"account_tokens_dialog_expires_x_days": "Belirtecin süresi {{days}} gün içinde dolacak",
"account_tokens_dialog_expires_never": "Belirtecin süresi asla dolmaz",
"account_tokens_delete_dialog_title": "Erişim belirtecini sil",
"account_tokens_delete_dialog_description": "Bir erişim belirtecini silmeden önce, hiçbir uygulamanın veya betiğin onu etkin olarak kullanmadığından emin olun. <strong>Bu işlem geri alınamaz</strong>.",
"account_tokens_delete_dialog_submit_button": "Belirteci kalıcı olarak sil",
"prefs_users_table_cannot_delete_or_edit": "Oturum açan kullanıcı silinemez veya düzenlenemez",
"prefs_reservations_title": "Ayırtılan konular",
"prefs_reservations_description": "Konu adlarını burada kişisel kullanım için ayırtabilirsiniz. Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.",
"prefs_reservations_limit_reached": "Ayırtılan konu sınırınıza ulaştınız.",
"prefs_reservations_edit_button": "Konu erişimini düzenle",
"prefs_reservations_table": "Ayırtılan konular tablosu",
"prefs_reservations_table_topic_header": "Konu",
"prefs_reservations_table_access_header": "Erişim",
"prefs_reservations_table_everyone_deny_all": "Yalnızca ben yayınlayabilir ve abone olabilirim",
"prefs_reservations_table_everyone_write_only": "Ben yayınlayabilir ve abone olabilirim, herkes yayınlayabilir",
"prefs_reservations_table_click_to_subscribe": "Abone olmak için tıklayın",
"prefs_reservations_dialog_title_add": "Konuyu ayırt",
"prefs_reservations_dialog_title_edit": "Ayırtılan konuyu düzenle",
"prefs_reservations_dialog_title_delete": "Konu ayırtmasını sil",
"prefs_reservations_dialog_description": "Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.",
"prefs_reservations_dialog_topic_label": "Konu",
"prefs_reservations_dialog_access_label": "Erişim",
"reservation_delete_dialog_action_keep_title": "Önbelleğe alınan mesajları ve ekleri sakla",
"reservation_delete_dialog_action_keep_description": "Sunucuda önbelleğe alınan mesajlar ve ekler, konu adını bilen kişiler için görülebilir hale gelecektir.",
"reservation_delete_dialog_action_delete_title": "Önbelleğe alınan mesajları ve ekleri sil",
"reservation_delete_dialog_action_delete_description": "Önbelleğe alınan mesajlar ve ekler kalıcı olarak silinecektir. Bu işlem geri alınamaz.",
"reservation_delete_dialog_submit_button": "Ayırtmayı sil",
"signup_title": "ntfy hesabı oluştur",
"signup_form_username": "Kullanıcı adı",
"signup_form_password": "Parola",
"action_bar_profile_title": "Profil",
"action_bar_profile_logout": "Oturumu kapat",
"action_bar_profile_settings": "Ayarlar",
"nav_upgrade_banner_description": "Konuları ayırtma, daha fazla mesaj ve e-posta, daha büyük ekler",
"display_name_dialog_title": "Görünen adı değiştir",
"account_basics_password_dialog_new_password_label": "Yeni parola",
"account_usage_basis_ip_description": "Bu hesabın kullanım istatistikleri ve sınırları IP adresinize dayalıdır, bu nedenle diğer kullanıcılarla paylaşılabilir. Yukarıda gösterilen sınırlar, mevcut hız sınırlarına dayalı olarak yaklaşık değerlerdir.",
"subscribe_dialog_subscribe_button_generate_topic_name": "Ad oluştur",
"account_basics_password_dialog_confirm_password_label": "Parolayı doğrula",
"account_basics_tier_payment_overdue": "Ödemenizin vadesi geçti. Lütfen ödeme yönteminizi güncelleyin, aksi takdirde hesabınızın seviyesi yakında düşürülecektir.",
"account_usage_messages_title": "Yayınlanan mesajlar",
"account_basics_tier_canceled_subscription": "Aboneliğiniz iptal edildi ve {{date}} tarihinde ücretsiz hesap seviyesine düşürülecek.",
"account_usage_emails_title": "Gönderilen e-postalar",
"account_upgrade_dialog_cancel_warning": "Bu, {{date}} tarihinde <strong>aboneliğinizi iptal edecek</strong> ve hesabınızın seviyesini düşürecektir. Bu tarihte, sunucuda önbelleğe alınan mesajlar ve ayırtılan konular <strong>silinecektir</strong>.",
"account_delete_dialog_label": "Parola",
"prefs_users_description_no_sync": "Kullanıcılar ve parolalar hesabınızla eşzamanlanmıyor.",
"account_upgrade_dialog_reservations_warning_one": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce <strong>lütfen en az bir ayırtmayı silin</strong>. Ayırtmaları <Link>Ayarlar</Link> sayfasından kaldırabilirsiniz.",
"account_tokens_dialog_title_create": "Erişim belirteci oluştur",
"account_tokens_description": "ntfy API aracılığıyla yayınlarken ve abone olurken erişim belirteçlerini kullanın, böylece hesap kimlik bilgilerinizi göndermek zorunda kalmazsınız. Daha fazla bilgi edinmek için <Link>belgelere</Link> bakın.",
"account_upgrade_dialog_button_update_subscription": "Aboneliği güncelle",
"account_tokens_table_last_access_header": "Son erişim",
"prefs_reservations_add_button": "Ayırtılan konu ekle",
"prefs_reservations_delete_button": "Konu erişimini sıfırla",
"prefs_reservations_table_everyone_read_only": "Ben yayınlayabilir ve abone olabilirim, herkes abone olabilir",
"prefs_reservations_table_not_subscribed": "Abone olunmadı",
"prefs_reservations_table_everyone_read_write": "Herkes yayınlayabilir ve abone olabilir",
"reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz."
}

View File

@ -3,7 +3,7 @@
"action_bar_unsubscribe": "取消訂閱",
"action_bar_toggle_mute": "通知靜音/解除通知靜音",
"action_bar_toggle_action_menu": "開啟/關閉操作選單",
"message_bar_type_message": "在這輸入訊息",
"message_bar_type_message": "在這輸入訊息",
"alert_grant_description": "允許瀏覽器權限以顯示桌面通知。",
"alert_grant_button": "允許",
"notifications_list": "通知清單",
@ -81,5 +81,121 @@
"error_boundary_title": "歐買尬ntfy 壞掉了",
"notifications_none_for_any_description": "要開始發送通知到一個主題,只需要對主題 URL 發送 HTTP PUT 或者 POST例如",
"notifications_no_subscriptions_description": "點選 「{{linktext}}」 連結以建立或訂閱主題。完成後,你就可以使用 HTTP PUT 或者 POST 發送通知到這裡了!",
"error_boundary_description": "很抱歉 ntfy 發生錯誤了。<br/>如果你有時間,煩請到<githubLink> Github </githubLink>回報錯誤,或者到<discordLink> Discord </discordLink>或者<matrixLink> Matrix 聊天室</matrixLink>裡面告訴我們。"
"error_boundary_description": "很抱歉 ntfy 發生錯誤了。<br/>如果你有時間,煩請到<githubLink> Github </githubLink>回報錯誤,或者到<discordLink> Discord </discordLink>或者<matrixLink> Matrix 聊天室</matrixLink>裡面告訴我們。",
"publish_dialog_tags_placeholder": "逗號分隔的標籤,例如 e.g. warning, srv1-backup",
"publish_dialog_click_label": "點擊網址",
"publish_dialog_attach_placeholder": "從網址新增附件,例如 https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "移除附件網址",
"publish_dialog_attach_label": "附件網址",
"publish_dialog_delay_reset": "移除延遲傳送",
"publish_dialog_delay_label": "延遲",
"publish_dialog_other_features": "其他功能:",
"publish_dialog_filename_placeholder": "附件檔案名稱",
"publish_dialog_delay_placeholder": "延遲傳送,例如 {{unixTimestamp}}, {{relativeTime}} 或 \"{{naturalLanguage}}\" (僅限英文)",
"publish_dialog_chip_click_label": "點擊網址",
"publish_dialog_chip_email_label": "轉發到電郵",
"publish_dialog_chip_attach_url_label": "從網址新增附件",
"emoji_picker_search_placeholder": "搜尋 emoji",
"subscribe_dialog_subscribe_title": "訂閱主題",
"subscribe_dialog_error_user_not_authorized": "用戶 {{username}} 沒有權限",
"subscribe_dialog_error_user_anonymous": "匿名",
"login_title": "登入 ntfy 帳戶",
"action_bar_reservation_add": "保留主題",
"action_bar_profile_logout": "登出",
"alert_not_supported_context_description": "訊息只支援 HTTPS. 這是受 <mdnLink>Notifications API</mdnLink> 的限制",
"publish_dialog_base_url_placeholder": "服務網址,例如 https://example.com",
"signup_title": "創建 ntfy 賬戶",
"signup_form_username": "用戶名稱",
"signup_form_password": "密碼",
"signup_form_button_submit": "註冊",
"signup_form_toggle_password_visibility": "顯示/隱藏密碼",
"signup_disabled": "註冊已停止",
"signup_error_username_taken": "用戶名稱 {{username}} 已被取用",
"signup_error_creation_limit_reached": "註冊賬戶限制",
"login_form_button_submit": "登入",
"login_link_signup": "註冊",
"signup_already_have_account": "已有帳戶? 立即登入!",
"login_disabled": "登入已停止",
"action_bar_account": "帳戶",
"action_bar_change_display_name": "改變顯示名稱",
"action_bar_reservation_edit": "改變已保留",
"action_bar_reservation_delete": "移除保留",
"action_bar_reservation_limit_reached": "達到限制",
"action_bar_profile_title": "簡介",
"action_bar_profile_settings": "設置",
"action_bar_sign_in": "登入",
"action_bar_sign_up": "註冊",
"nav_button_account": "帳戶",
"nav_upgrade_banner_label": "升級到 ntfy 專業版",
"nav_upgrade_banner_description": "保留主題,更多信息電郵及附件",
"display_name_dialog_title": "改變顯示名稱",
"display_name_dialog_description": "為主題新增在訂閱清單顯示的第二名稱, 這會令尋找複雜主題時更方便。",
"display_name_dialog_placeholder": "顯示名稱",
"reserve_dialog_checkbox_label": "保留主題及設置權限",
"publish_dialog_progress_uploading_detail": "上載中 {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "已公佈通訊",
"publish_dialog_attachment_limits_file_reached": "超出檔案限制 {fileSizeLimit}}",
"publish_dialog_attachment_limits_quota_reached": "超出限制, 尚餘 {{remainingBytes}}",
"publish_dialog_emoji_picker_show": "選擇 emoji",
"publish_dialog_priority_min": "最低優先",
"publish_dialog_priority_low": "較低優先",
"publish_dialog_priority_default": "正常優先",
"publish_dialog_priority_high": "高度優先",
"publish_dialog_priority_max": "最高優先",
"publish_dialog_base_url_label": "服務網址",
"publish_dialog_topic_label": "主題名稱",
"publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts",
"publish_dialog_topic_reset": "重置主題",
"publish_dialog_title_label": "標題",
"publish_dialog_title_placeholder": "通訊標題,例如 Disk space alert",
"publish_dialog_message_label": "訊息",
"publish_dialog_message_placeholder": "這裏輸入訊息",
"publish_dialog_tags_label": "標籤",
"publish_dialog_click_placeholder": "通訊被點擊時到訪的網址",
"publish_dialog_click_reset": "移除點擊網址",
"publish_dialog_email_reset": "移除電郵轉發",
"publish_dialog_chip_attach_file_label": "上載檔案",
"publish_dialog_chip_delay_label": "延遲傳送",
"publish_dialog_chip_topic_label": "更變主題",
"publish_dialog_details_examples_description": "可以在 <docsLink>documentation</docsLink> 找到詳細的功能說明及例子。",
"publish_dialog_checkbox_publish_another": "公佈更多",
"publish_dialog_attached_file_title": "附件:",
"publish_dialog_attached_file_filename_placeholder": "附件名稱",
"subscribe_dialog_subscribe_use_another_label": "使用另一個伺服器",
"subscribe_dialog_subscribe_base_url_label": "服務網址",
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱",
"subscribe_dialog_login_title": "需要登入",
"subscribe_dialog_login_username_label": "用戶名稱,例如 phil",
"subscribe_dialog_error_topic_already_reserved": "主題已被保留",
"account_basics_title": "帳戶",
"account_basics_username_title": "用戶名稱",
"account_basics_username_description": "這就是你了❤",
"account_basics_username_admin_tooltip": "你是管理員",
"account_basics_password_title": "密碼",
"account_basics_password_description": "更變你的密碼",
"account_basics_password_dialog_title": "更變密碼",
"account_basics_password_dialog_new_password_label": "新的密碼",
"account_basics_password_dialog_confirm_password_label": "確認密碼",
"account_basics_password_dialog_button_submit": "更變密碼",
"account_usage_unlimited": "無限制",
"account_usage_title": "已經使用",
"account_usage_limits_reset_daily": "使用限制每天午夜重置",
"account_basics_tier_title": "帳戶類型",
"account_basics_tier_description": "你的能量值",
"account_basics_tier_admin": "管理員",
"account_basics_tier_admin_suffix_with_tier": "(擁有 {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(無層)",
"account_basics_tier_basic": "基礎",
"account_basics_tier_free": "免費",
"account_basics_tier_upgrade_button": "升級至專業版",
"publish_dialog_email_placeholder": "轉發到電郵,例如 phil@example.com",
"subscribe_dialog_subscribe_topic_placeholder": "主題名稱,例如 phil_alerts",
"publish_dialog_attached_file_remove": "移除附件",
"subscribe_dialog_subscribe_description": "主題可能不受到密碼保護, 所以盡量選擇一個不會容易被猜中的主題名稱。 一旦已訂閱,你能夠 PUT/POST 通訊。",
"subscribe_dialog_login_description": "這個主題受密碼保護,請輸入用戶名稱及密碼以訂閱主題。",
"account_basics_password_dialog_current_password_label": "現在的密碼",
"account_basics_password_dialog_current_password_incorrect": "密碼不正確",
"account_basics_tier_change_button": "更變",
"common_add": "新增",
"signup_form_confirm_password": "確認密碼"
}

View File

@ -257,23 +257,24 @@ class AccountApi {
return this.tiers;
}
async createBillingSubscription(tier) {
console.log(`[AccountApi] Creating billing subscription with ${tier}`);
return await this.upsertBillingSubscription("POST", tier)
async createBillingSubscription(tier, interval) {
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("POST", tier, interval)
}
async updateBillingSubscription(tier) {
console.log(`[AccountApi] Updating billing subscription with ${tier}`);
return await this.upsertBillingSubscription("PUT", tier)
async updateBillingSubscription(tier, interval) {
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("PUT", tier, interval)
}
async upsertBillingSubscription(method, tier) {
async upsertBillingSubscription(method, tier, interval) {
const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, {
method: method,
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
tier: tier
tier: tier,
interval: interval
})
});
return await response.json(); // May throw SyntaxError
@ -371,6 +372,12 @@ export const SubscriptionStatus = {
PAST_DUE: "past_due"
};
// Maps to stripe.PriceRecurringInterval
export const SubscriptionInterval = {
MONTH: "month",
YEAR: "year"
};
// Maps to user.Permission in user/types.go
export const Permission = {
READ_WRITE: "read-write",

View File

@ -212,6 +212,13 @@ export const formatNumber = (n) => {
return n;
}
export const formatPrice = (n) => {
if (n % 100 === 0) {
return `$${n/100}`;
}
return `$${(n/100).toPrecision(2)}`;
}
export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer");
};

View File

@ -35,7 +35,7 @@ import TextField from "@mui/material/TextField";
import routes from "./routes";
import IconButton from "@mui/material/IconButton";
import {formatBytes, formatShortDate, formatShortDateTime, openUrl} from "../app/utils";
import accountApi, {LimitBasis, Role, SubscriptionStatus} from "../app/AccountApi";
import accountApi, {LimitBasis, Role, SubscriptionInterval, SubscriptionStatus} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref";
import db from "../app/db";
@ -248,6 +248,11 @@ const AccountType = () => {
accountType = (config.enable_payments) ? t("account_basics_tier_free") : t("account_basics_tier_basic");
} else {
accountType = account.tier.name;
if (account.billing?.interval === SubscriptionInterval.MONTH) {
accountType += ` (${t("account_basics_tier_interval_monthly")})`;
} else if (account.billing?.interval === SubscriptionInterval.YEAR) {
accountType += ` (${t("account_basics_tier_interval_yearly")})`;
}
}
return (
@ -451,7 +456,7 @@ const Tokens = () => {
<Trans
i18nKey="account_tokens_description"
components={{
Link: <Link href="/docs"/>
Link: <Link href="/docs/publish/#access-tokens"/>
}}
/>
</Paragraph>

View File

@ -456,6 +456,7 @@ const Language = () => {
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ar">العربية</MenuItem>
<MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem>

View File

@ -75,7 +75,7 @@ const SubscribePage = (props) => {
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
.filter(s => s !== config.base_url);
const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);
const reserveTopicEnabled = session.exists() && account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0;
const reserveTopicEnabled = session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined

View File

@ -11,7 +11,7 @@ import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import accountApi from "../app/AccountApi";
import accountApi, {Role} from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import MenuItem from "@mui/material/MenuItem";
@ -255,7 +255,7 @@ const DisplayNameDialog = (props) => {
export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext);
if (account?.stats.reservations_remaining > 0) {
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
return <></>;
} else if (config.enable_payments) {
return (account?.limits.reservations > 0) ? <LimitReachedChip/> : <ProChip/>;

View File

@ -3,20 +3,20 @@ import {useContext, useEffect, useState} from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import {Alert, CardActionArea, CardContent, ListItem, useMediaQuery} from "@mui/material";
import {Alert, Badge, CardActionArea, CardContent, Chip, ListItem, Stack, Switch, useMediaQuery} from "@mui/material";
import theme from "./theme";
import DialogFooter from "./DialogFooter";
import Button from "@mui/material/Button";
import accountApi from "../app/AccountApi";
import accountApi, {SubscriptionInterval} from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import {AccountContext} from "./App";
import {formatBytes, formatNumber, formatShortDate} from "../app/utils";
import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils";
import {Trans, useTranslation} from "react-i18next";
import List from "@mui/material/List";
import {Check} from "@mui/icons-material";
import {Check, Close} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Box from "@mui/material/Box";
@ -28,6 +28,7 @@ const UpgradeDialog = (props) => {
const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState("");
const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
@ -46,6 +47,7 @@ const UpgradeDialog = (props) => {
const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier})));
const newTier = tiersMap[newTierCode]; // May be undefined
const currentTier = account?.tier; // May be undefined
const currentInterval = account?.billing?.interval; // May be undefined
const currentTierCode = currentTier?.code; // May be undefined
// Figure out buttons, labels and the submit action
@ -54,7 +56,7 @@ const UpgradeDialog = (props) => {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP;
banner = null;
} else if (currentTierCode === newTierCode) {
} else if (currentTierCode === newTierCode && currentInterval === interval) {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = null;
banner = (currentTierCode) ? Banner.PRORATION_INFO : null;
@ -88,10 +90,10 @@ const UpgradeDialog = (props) => {
try {
setLoading(true);
if (submitAction === Action.CREATE_SUBSCRIPTION) {
const response = await accountApi.createBillingSubscription(newTierCode);
const response = await accountApi.createBillingSubscription(newTierCode, interval);
window.location.href = response.redirect_url;
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
await accountApi.updateBillingSubscription(newTierCode);
await accountApi.updateBillingSubscription(newTierCode, interval);
} else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
await accountApi.deleteBillingSubscription();
}
@ -108,15 +110,58 @@ const UpgradeDialog = (props) => {
}
}
// Figure out discount
let discount = 0, upto = false;
if (newTier?.prices) {
discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100);
} else {
let n = 0;
for (const t of tiers) {
if (t.prices) {
const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100);
if (tierDiscount > discount) {
discount = tierDiscount;
n++;
}
}
}
upto = n > 1;
}
return (
<Dialog
open={props.open}
onClose={props.onCancel}
maxWidth="md"
fullWidth
maxWidth="lg"
fullScreen={fullScreen}
>
<DialogTitle>{t("account_upgrade_dialog_title")}</DialogTitle>
<DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
<div style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "4px"
}}>
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_monthly")}</Typography>
<Switch
checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
/>
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_yearly")}</Typography>
{discount > 0 &&
<Chip
label={upto ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount }) : t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })}
color="primary"
size="small"
variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"}
sx={{ marginLeft: "5px" }}
/>
}
</div>
</div>
</DialogTitle>
<DialogContent>
<div style={{
display: "flex",
@ -130,24 +175,25 @@ const UpgradeDialog = (props) => {
tier={tier}
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
selected={newTierCode === tier.code} // tier.code may be undefined!
interval={interval}
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
/>
)}
</div>
{banner === Banner.CANCEL_WARNING &&
<Alert severity="warning">
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} />
</Alert>
}
{banner === Banner.PRORATION_INFO &&
<Alert severity="info">
<Alert severity="info" sx={{ fontSize: "1rem" }}>
<Trans i18nKey="account_upgrade_dialog_proration_info" />
</Alert>
}
{banner === Banner.RESERVATIONS_WARNING &&
<Alert severity="warning">
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_reservations_warning"
count={account?.reservations.length - newTier?.limits.reservations}
@ -169,28 +215,37 @@ const UpgradeDialog = (props) => {
const TierCard = (props) => {
const { t } = useTranslation();
const tier = props.tier;
let cardStyle, labelStyle, labelText;
if (props.selected) {
cardStyle = { background: "#eee", border: "2px solid #338574" };
cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" };
labelText = t("account_upgrade_dialog_tier_selected_label");
} else if (props.current) {
cardStyle = { border: "2px solid #eee" };
cardStyle = { border: "3px solid #eee" };
labelStyle = { background: "#eee", color: "black" };
labelText = t("account_upgrade_dialog_tier_current_label");
} else {
cardStyle = { border: "2px solid transparent" };
cardStyle = { border: "3px solid transparent" };
}
let monthlyPrice;
if (!tier.prices) {
monthlyPrice = 0;
} else if (props.interval === SubscriptionInterval.YEAR) {
monthlyPrice = tier.prices.year/12;
} else if (props.interval === SubscriptionInterval.MONTH) {
monthlyPrice = tier.prices.month;
}
return (
<Box sx={{
m: "7px",
minWidth: "190px",
maxWidth: "250px",
minWidth: "240px",
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
borderRadius: "3px",
borderRadius: "5px",
"&:first-of-type": { ml: 0 },
"&:last-of-type": { mr: 0 },
...cardStyle
@ -208,19 +263,29 @@ const TierCard = (props) => {
...labelStyle
}}>{labelText}</div>
}
<Typography variant="h5" component="div">
<Typography variant="subtitle1" component="div">
{tier.name || t("account_basics_tier_free")}
</Typography>
<div>
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography>
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>}
</div>
<List dense>
{tier.limits.reservations > 0 && <FeatureItem>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</FeatureItem>}
<FeatureItem>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })}</FeatureItem>
<FeatureItem>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails) })}</FeatureItem>
<FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</FeatureItem>
<FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</FeatureItem>
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</Feature>}
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails) })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature>
</List>
{tier.price &&
<Typography variant="subtitle1" sx={{fontWeight: 500}}>
{tier.price} / month
{tier.prices && props.interval === SubscriptionInterval.MONTH &&
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })}
</Typography>
}
{tier.prices && props.interval === SubscriptionInterval.YEAR &&
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })}
</Typography>
}
</CardContent>
@ -231,16 +296,25 @@ const TierCard = (props) => {
);
}
const Feature = (props) => {
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
}
const NoFeature = (props) => {
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
}
const FeatureItem = (props) => {
return (
<ListItem disableGutters sx={{m: 0, p: 0}}>
<ListItemIcon sx={{minWidth: "24px"}}>
<Check fontSize="small" sx={{ color: "#338574" }}/>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }}/>}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }}/>}
</ListItemIcon>
<ListItemText
sx={{mt: "2px", mb: "2px"}}
primary={
<Typography variant="body2">
<Typography variant="body1">
{props.children}
</Typography>
}