Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1bcc26cfe | ||
|
|
0967414f79 | ||
|
|
f4772b0c75 | ||
|
|
8215b66db3 | ||
|
|
d0a98afc49 | ||
|
|
da3a5681d9 | ||
|
|
f7f343fe55 | ||
|
|
0606fbe60a | ||
|
|
b2bedafae7 | ||
|
|
c108e8d856 | ||
|
|
5b5509d07c | ||
|
|
0d7aba9487 | ||
|
|
fbbfa2bbc1 | ||
|
|
2f5cfab01c | ||
|
|
70cd267ff5 | ||
|
|
d5052d79e6 | ||
|
|
a372eb99b7 | ||
|
|
199933b752 | ||
|
|
45928ddc47 | ||
|
|
bfc3983d06 | ||
|
|
2329695a47 | ||
|
|
ab1dbb04bd | ||
|
|
1fe19e41fb | ||
|
|
a47ac2a5b5 | ||
|
|
8eae44ea61 | ||
|
|
57e1104afb | ||
|
|
ede957973b | ||
|
|
697c09e146 | ||
|
|
ab59d81d08 | ||
|
|
c8d3b665f5 | ||
|
|
422ad0cc5d | ||
|
|
0c3d832c5f | ||
|
|
483410c4a2 | ||
|
|
bdeec4d297 | ||
|
|
21b27b5dbe | ||
|
|
29340e7e24 | ||
|
|
4ab450309f | ||
|
|
2ac63c4327 | ||
|
|
c31b9236a1 | ||
|
|
1da4187405 | ||
|
|
41282e2c73 | ||
|
|
3d40acc26b | ||
|
|
f7ed0eb4e7 | ||
|
|
9eadaf4c3a | ||
|
|
ce7d447f16 | ||
|
|
ef9d6d9f6c | ||
|
|
0e4044b747 | ||
|
|
bc3d897d7a | ||
|
|
1655f584f9 | ||
|
|
07afaf961d | ||
|
|
2b2a1eca9c | ||
|
|
3dd964f42c | ||
|
|
44aa7f4053 | ||
|
|
965fc2016d | ||
|
|
fd470702ab | ||
|
|
d17d86da95 | ||
|
|
f8a70c6025 | ||
|
|
587cc48b24 | ||
|
|
0c430c37bc | ||
|
|
273b911ccf | ||
|
|
a51228b374 | ||
|
|
568b336913 | ||
|
|
ab5fc36fb7 | ||
|
|
ff78ecc195 | ||
|
|
bf2acbf617 | ||
|
|
f18b98d75b | ||
|
|
16c5c74923 | ||
|
|
3586fc90ca | ||
|
|
67b45455b8 | ||
|
|
d92d1ad974 | ||
|
|
0177016fbc | ||
|
|
36685e9df9 | ||
|
|
61f403bff4 | ||
|
|
83d7dd99e8 | ||
|
|
224eae2d2d | ||
|
|
cf6997797e | ||
|
|
33e75375fd | ||
|
|
b0540c1162 | ||
|
|
4093a8ea5b | ||
|
|
e892b994c3 | ||
|
|
5f75e98861 | ||
|
|
e9b05e8ed7 | ||
|
|
1edcc239e5 | ||
|
|
61d09cf033 | ||
|
|
227ea8ecc5 | ||
|
|
7e4fb3caed | ||
|
|
152dfbbb54 | ||
|
|
c3f29bdc41 | ||
|
|
fb727fc84a | ||
|
|
9377c265a8 | ||
|
|
59b59fda98 | ||
|
|
96439ac41f | ||
|
|
c9a5d00b89 | ||
|
|
9efc1ec4f6 | ||
|
|
85fc16b016 | ||
|
|
5287fa1c94 | ||
|
|
1c54be3581 | ||
|
|
484fd91452 | ||
|
|
9ff3bb0c87 | ||
|
|
38e7801b41 | ||
|
|
7fb6f794e5 | ||
|
|
df68b0cb43 | ||
|
|
ca49fd1161 | ||
|
|
bb3f17ada2 | ||
|
|
d18c61f0da | ||
|
|
92cfc04024 | ||
|
|
2d0ce79011 | ||
|
|
c6e091a754 | ||
|
|
c8c16eb8e6 | ||
|
|
0e1082b09c | ||
|
|
c815b183d4 | ||
|
|
a95d1f9200 | ||
|
|
6451762508 | ||
|
|
b9badee6db | ||
|
|
c6b64df662 | ||
|
|
7c5b9c0e62 | ||
|
|
6bfe4a9779 | ||
|
|
fb2fa4c478 | ||
|
|
28b654ae27 | ||
|
|
d686e1ee77 |
10
README.md
10
README.md
@@ -13,11 +13,9 @@
|
||||
[](https://ntfy.statuspage.io/)
|
||||
[](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">
|
||||
@@ -117,6 +115,10 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||
<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:
|
||||
|
||||
@@ -86,6 +86,7 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||
|
||||
@@ -250,6 +250,7 @@ func execServe(c *cli.Context) error {
|
||||
|
||||
// Stripe things
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func init() {
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
t.Parallel()
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
go func() {
|
||||
|
||||
36
cmd/tier.go
36
cmd/tier.go
@@ -54,7 +54,8 @@ 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.
|
||||
@@ -96,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.
|
||||
|
||||
@@ -110,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
|
||||
`,
|
||||
},
|
||||
@@ -166,6 +169,10 @@ 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 {
|
||||
@@ -206,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
|
||||
@@ -273,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
|
||||
@@ -319,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)
|
||||
@@ -333,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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
50
docs/_overrides/main.html
Normal file
50
docs/_overrides/main.html
Normal 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 %}
|
||||
@@ -1179,9 +1179,9 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `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 |
|
||||
|
||||
|
||||
@@ -572,4 +572,27 @@ Example `template.html`:
|
||||
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
|
||||

|
||||
|
||||
## Traccar
|
||||
This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes
|
||||
|
||||
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
||||
|
||||
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
||||
```xml
|
||||
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
||||
<entry key='sms.http.template'>
|
||||
{
|
||||
"topic": "{phone}",
|
||||
"message": "{message}"
|
||||
}
|
||||
</entry>
|
||||
```
|
||||
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token
|
||||
```xml
|
||||
<entry key='sms.http.authorization'>Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ</entry>
|
||||
```
|
||||
or by simply providing traccar with a valid username/password combination.
|
||||
```xml
|
||||
<entry key='sms.http.user'>phil</entry>
|
||||
<entry key='sms.http.password'>mypass</entry>
|
||||
```
|
||||
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz > ntfy_2.0.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.0.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_macOS_all.tar.gz > ntfy_2.1.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.1.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.0.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.1.0_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/v2.0.0/ntfy_2.0.0_windows_x86_64.zip),
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_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).
|
||||
|
||||
@@ -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,10 +113,15 @@ 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
|
||||
|
||||
@@ -1292,7 +1292,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
```
|
||||
|
||||
The required/optional fields for each action depend on the type of the action itself. Please refer to
|
||||
[`view` action](#open-websiteapp), [`broadcasst` action](#send-android-broadcast), and [`http` action](#send-http-request)
|
||||
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request)
|
||||
for details.
|
||||
|
||||
### Open website/app
|
||||
@@ -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)):
|
||||
@@ -2930,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:
|
||||
|
||||
|
||||
@@ -2,7 +2,51 @@
|
||||
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 v2.0.0 (UNRELEASED)
|
||||
## ntfy server v2.1.0
|
||||
Released February 25, 2023
|
||||
|
||||
This release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based
|
||||
rate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits.
|
||||
However, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do
|
||||
no, clients will receive an HTTP 507 response from the server.
|
||||
|
||||
We also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers,
|
||||
which ntfy rejected with an HTTP 401. We now ignore unsupported header values.
|
||||
|
||||
As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing
|
||||
cycles (not live yet).
|
||||
|
||||
**Features:**
|
||||
|
||||
* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
|
||||
* 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))
|
||||
* Payments: 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)
|
||||
* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore))
|
||||
|
||||
**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.
|
||||
@@ -64,6 +108,11 @@ going. It'll only make ntfy better.
|
||||
* 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
|
||||
|
||||
@@ -95,11 +144,6 @@ breaking-change upgrade, which required some work to get working again.
|
||||
|
||||
* 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 🎅
|
||||
|
||||
|
||||
2
docs/static/css/extra.css
vendored
2
docs/static/css/extra.css
vendored
@@ -2,13 +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;
|
||||
}
|
||||
|
||||
|
||||
.md-header__topic:first-child {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
8
go.mod
8
go.mod
@@ -27,20 +27,20 @@ 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.7.0
|
||||
github.com/stripe/stripe-go/v74 v74.9.0
|
||||
)
|
||||
|
||||
require (
|
||||
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.10.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
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
@@ -58,7 +58,7 @@ require (
|
||||
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-20230209215440-0dfe4f8abfcc // indirect
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // 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
|
||||
|
||||
18
go.sum
18
go.sum
@@ -7,8 +7,8 @@ cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGB
|
||||
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.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
|
||||
cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
||||
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=
|
||||
@@ -44,6 +44,8 @@ github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3r
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
@@ -101,8 +103,10 @@ 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.7.0 h1:KHlyslQj9YOv62b1sycQ31LFj7KlqR+seHsSowAWrjc=
|
||||
github.com/stripe/stripe-go/v74 v74.7.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||
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/stripe/stripe-go/v74 v74.9.0 h1:yQ3O8jmtoAjKARzjLGmwYj2ZxqYbdtWVjFeovNGDtjg=
|
||||
github.com/stripe/stripe-go/v74 v74.9.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=
|
||||
@@ -176,8 +180,10 @@ 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-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
|
||||
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/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/genproto v0.0.0-20230223222841-637eb2293923 h1:znp6mq/drrY+6khTAlJUDNFFcDGV2ENLYKpMq8SyCds=
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw=
|
||||
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=
|
||||
|
||||
45
log/event.go
45
log/event.go
@@ -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
|
||||
@@ -118,27 +119,27 @@ func (e *Event) Fields(fields Context) *Event {
|
||||
return e
|
||||
}
|
||||
|
||||
// With adds the fields of the given Contexter structs to the log event by calling their With method
|
||||
func (e *Event) With(contexts ...Contexter) *Event {
|
||||
// With adds the fields of the given Contexter structs to the log event by calling their Context method
|
||||
func (e *Event) With(contexters ...Contexter) *Event {
|
||||
if e.contexters == nil {
|
||||
e.contexters = contexts
|
||||
e.contexters = contexters
|
||||
} else {
|
||||
e.contexters = append(e.contexters, contexts...)
|
||||
e.contexters = append(e.contexters, contexters...)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
log/log.go
48
log/log.go
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -102,6 +102,13 @@ type Contexter interface {
|
||||
// Context represents an object's state in the form of key-value pairs
|
||||
type Context map[string]any
|
||||
|
||||
// Merge merges other into this context
|
||||
func (c Context) Merge(other Context) {
|
||||
for k, v := range other {
|
||||
c[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
type levelOverride struct {
|
||||
value string
|
||||
level Level
|
||||
|
||||
@@ -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
|
||||
|
||||
150
server/errors.go
150
server/errors.go
@@ -13,6 +13,7 @@ type errHTTP struct {
|
||||
HTTPCode int `json:"http"`
|
||||
Message string `json:"error"`
|
||||
Link string `json:"link,omitempty"`
|
||||
context log.Context
|
||||
}
|
||||
|
||||
func (e errHTTP) Error() string {
|
||||
@@ -25,71 +26,106 @@ func (e errHTTP) JSON() string {
|
||||
}
|
||||
|
||||
func (e errHTTP) Context() log.Context {
|
||||
return log.Context{
|
||||
context := log.Context{
|
||||
"error": e.Message,
|
||||
"error_code": e.Code,
|
||||
"http_status": e.HTTPCode,
|
||||
}
|
||||
for k, v := range e.context {
|
||||
context[k] = v
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
||||
return &errHTTP{
|
||||
Code: err.Code,
|
||||
HTTPCode: err.HTTPCode,
|
||||
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
|
||||
Link: err.Link,
|
||||
func (e errHTTP) Wrap(message string, args ...any) *errHTTP {
|
||||
clone := e.clone()
|
||||
clone.Message = fmt.Sprintf("%s, %s", clone.Message, fmt.Sprintf(message, args...))
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (e errHTTP) With(contexters ...log.Contexter) *errHTTP {
|
||||
c := e.clone()
|
||||
if c.context == nil {
|
||||
c.context = make(log.Context)
|
||||
}
|
||||
for _, contexter := range contexters {
|
||||
c.context.Merge(contexter.Context())
|
||||
}
|
||||
return &c
|
||||
}
|
||||
|
||||
func (e errHTTP) Fields(context log.Context) *errHTTP {
|
||||
c := e.clone()
|
||||
if c.context == nil {
|
||||
c.context = make(log.Context)
|
||||
}
|
||||
c.context.Merge(context)
|
||||
return &c
|
||||
}
|
||||
|
||||
func (e errHTTP) clone() errHTTP {
|
||||
context := make(log.Context)
|
||||
for k, v := range e.context {
|
||||
context[k] = v
|
||||
}
|
||||
return errHTTP{
|
||||
Code: e.Code,
|
||||
HTTPCode: e.HTTPCode,
|
||||
Message: e.Message,
|
||||
Link: e.Link,
|
||||
context: context,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", ""}
|
||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
|
||||
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
|
||||
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
|
||||
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
|
||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
|
||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
|
||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", ""}
|
||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
|
||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
||||
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
|
||||
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
|
||||
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
|
||||
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
|
||||
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
|
||||
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
|
||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
|
||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
|
||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", ""}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
|
||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
|
||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil}
|
||||
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil}
|
||||
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil}
|
||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil}
|
||||
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil}
|
||||
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil}
|
||||
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil}
|
||||
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil}
|
||||
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil}
|
||||
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil}
|
||||
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil}
|
||||
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil}
|
||||
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil}
|
||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
|
||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
|
||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
||||
errHTTPInsufficientStorage = &errHTTP{50701, http.StatusInsufficientStorage, "internal server error: cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
|
||||
)
|
||||
|
||||
@@ -30,6 +30,10 @@ const (
|
||||
tagMatrix = "matrix"
|
||||
)
|
||||
|
||||
var (
|
||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusInsufficientStorage}
|
||||
)
|
||||
|
||||
// logr creates a new log event with HTTP request fields
|
||||
func logr(r *http.Request) *log.Event {
|
||||
return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
199
server/server.go
199
server/server.go
@@ -45,11 +45,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
|
||||
}
|
||||
@@ -111,6 +111,8 @@ const (
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||
jsonBodyBytesLimit = 16384
|
||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||
unifiedPushTopicLength = 14
|
||||
)
|
||||
|
||||
// WebSocket constants
|
||||
@@ -317,25 +319,21 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
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)
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
|
||||
ev := logvr(v, r).Err(err)
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
ev.Tag(tagWebsocket).Fields(websocketErrorContext(err))
|
||||
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())
|
||||
ev.Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error())
|
||||
} else {
|
||||
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Info("WebSocket error: %s", err.Error())
|
||||
ev.Info("WebSocket error: %s", err.Error())
|
||||
}
|
||||
return // Do not attempt to write to upgraded connection
|
||||
}
|
||||
if matrixErr, ok := err.(*errMatrix); ok {
|
||||
if err := writeMatrixError(w, r, v, matrixErr); err != nil {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Writing Matrix error failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if isNormalError {
|
||||
logvr(v, r).Err(httpErr).Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
ev.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)
|
||||
ev.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
|
||||
@@ -403,13 +401,13 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
||||
return s.limitRequests(s.transformBodyJSON(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||
return s.limitRequests(s.transformMatrixJSON(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeJSON))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
|
||||
@@ -568,28 +566,36 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !v.MessageAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitMessages
|
||||
}
|
||||
t := fromContext[topic](r, contextTopic)
|
||||
vrate := fromContext[visitor](r, contextRateVisitor)
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
if unifiedpush && t.RateVisitor() == nil {
|
||||
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
|
||||
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
|
||||
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
|
||||
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
|
||||
return nil, errHTTPInsufficientStorage.With(t)
|
||||
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||
} else if email != "" && !vrate.EmailAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
||||
}
|
||||
if m.PollID != "" {
|
||||
m = newPollRequestMessage(t.ID, m.PollID)
|
||||
}
|
||||
m.Sender = v.IP()
|
||||
m.User = v.MaybeUserID()
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.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
|
||||
}
|
||||
@@ -599,6 +605,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||
delayed := m.Time > time.Now().Unix()
|
||||
ev := logvrm(v, r, m).
|
||||
Tag(tagPublish).
|
||||
With(t).
|
||||
Fields(log.Context{
|
||||
"message_delayed": delayed,
|
||||
"message_firebase": firebase,
|
||||
@@ -653,7 +660,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
_, err := s.handlePublishWithoutResponse(r, v)
|
||||
if err != nil {
|
||||
return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err}
|
||||
return err
|
||||
}
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
@@ -700,7 +707,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) {
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
@@ -739,11 +746,6 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if email != "" {
|
||||
if !v.EmailAllowed() {
|
||||
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
}
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
@@ -751,17 +753,12 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
if messageStr != "" {
|
||||
m.Message = messageStr
|
||||
}
|
||||
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if err != nil {
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
||||
if tagsStr != "" {
|
||||
m.Tags = make([]string, 0)
|
||||
for _, s := range util.SplitNoEmpty(tagsStr, ",") {
|
||||
m.Tags = append(m.Tags, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
@@ -782,9 +779,9 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
}
|
||||
actionsStr := readParam(r, "x-actions", "actions", "action")
|
||||
if actionsStr != "" {
|
||||
m.Actions, err = parseActions(actionsStr)
|
||||
if err != nil {
|
||||
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
@@ -848,7 +845,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead
|
||||
|
||||
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||
if !utf8.Valid(body.PeekedBytes) {
|
||||
return errHTTPBadRequestMessageNotUTF8
|
||||
return errHTTPBadRequestMessageNotUTF8.With(m)
|
||||
}
|
||||
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
||||
m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
|
||||
@@ -861,7 +858,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed
|
||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||
}
|
||||
vinfo, err := v.Info()
|
||||
if err != nil {
|
||||
@@ -898,7 +895,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||
if err == util.ErrLimitReached {
|
||||
return errHTTPEntityTooLargeAttachment
|
||||
return errHTTPEntityTooLargeAttachment.With(m)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -951,7 +948,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
poll, since, scheduled, filters, err := parseSubscribeParams(r)
|
||||
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -981,6 +978,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
|
||||
if poll {
|
||||
@@ -1033,7 +1033,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
poll, since, scheduled, filters, err := parseSubscribeParams(r)
|
||||
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1116,6 +1116,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
}
|
||||
return conn.WriteJSON(msg)
|
||||
}
|
||||
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
if poll {
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
@@ -1143,7 +1146,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return err
|
||||
}
|
||||
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) {
|
||||
poll = readBoolParam(r, false, "x-poll", "poll", "po")
|
||||
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
|
||||
since, err = parseSince(r, poll)
|
||||
@@ -1154,9 +1157,68 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics")
|
||||
return
|
||||
}
|
||||
|
||||
// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published
|
||||
// to that topic will be rate limited against the rate visitor instead of the publishing visitor.
|
||||
//
|
||||
// Setting the rate visitor is ony allowed if
|
||||
// - auth-file is not set (everything is open by default)
|
||||
// - the topic is reserved, and v.user is the owner
|
||||
// - the topic is not reserved, and v.user has write access
|
||||
//
|
||||
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
|
||||
// until the Android app will send the "Rate-Topics" header.
|
||||
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
|
||||
// Make a list of topics that we'll actually set the RateVisitor on
|
||||
eligibleRateTopics := make([]*topic, 0)
|
||||
for _, t := range topics {
|
||||
if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) {
|
||||
eligibleRateTopics = append(eligibleRateTopics, t)
|
||||
}
|
||||
}
|
||||
if len(eligibleRateTopics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If access controls are turned off, v has access to everything, and we can set the rate visitor
|
||||
if s.userManager == nil {
|
||||
return s.setRateVisitors(r, v, eligibleRateTopics)
|
||||
}
|
||||
|
||||
// If access controls are enabled, only set rate visitor if
|
||||
// - topic is reserved, and v.user is the owner
|
||||
// - topic is not reserved, and v.user has write access
|
||||
writableRateTopics := make([]*topic, 0)
|
||||
for _, t := range topics {
|
||||
ownerUserID, err := s.userManager.ReservationOwner(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerUserID == "" {
|
||||
if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil {
|
||||
writableRateTopics = append(writableRateTopics, t)
|
||||
}
|
||||
} else if ownerUserID == v.MaybeUserID() {
|
||||
writableRateTopics = append(writableRateTopics, t)
|
||||
}
|
||||
}
|
||||
return s.setRateVisitors(r, v, writableRateTopics)
|
||||
}
|
||||
|
||||
func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topic) error {
|
||||
for _, t := range rateTopics {
|
||||
logvr(v, r).
|
||||
Tag(tagSubscribe).
|
||||
With(t).
|
||||
Debug("Setting visitor as rate visitor for topic %s", t.ID)
|
||||
t.SetRateVisitor(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
||||
// marker, returning only messages that are newer than the marker.
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||
@@ -1462,11 +1524,15 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Trace("Invalid Matrix request")
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
|
||||
if e, ok := err.(*errMatrixPushkeyRejected); ok {
|
||||
return writeMatrixResponse(w, e.rejectedPushKey)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := next(w, newRequest, v); err != nil {
|
||||
return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err}
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Error handling Matrix request")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1492,8 +1558,8 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
|
||||
u := v.User()
|
||||
for _, t := range topics {
|
||||
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
|
||||
logvr(v, r).Err(err).Field("message_topic", t.ID).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden
|
||||
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden.With(t)
|
||||
}
|
||||
}
|
||||
return next(w, r, v)
|
||||
@@ -1503,7 +1569,8 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
|
||||
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
|
||||
// if it is set.
|
||||
//
|
||||
// - If the header is not set, an IP-based visitor is returned
|
||||
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
|
||||
// an IP-based visitor is returned
|
||||
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
|
||||
// or the token (Bearer auth), and read the user from the database
|
||||
//
|
||||
@@ -1516,7 +1583,7 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return vip, err
|
||||
} else if header == "" {
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return vip, nil
|
||||
} else if s.userManager == nil {
|
||||
return vip, errHTTPUnauthorized
|
||||
@@ -1561,6 +1628,14 @@ func readAuthHeader(r *http.Request) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// supportedAuthHeader returns true only if the Authorization header value starts
|
||||
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
|
||||
// are things like "WebPush", or "vapid" (see #629).
|
||||
func supportedAuthHeader(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
|
||||
r.Header.Set("Authorization", value)
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -506,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
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_ExtendToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
@@ -611,6 +612,7 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
s := newTestServer(t, conf)
|
||||
@@ -685,6 +687,7 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.EnableSignup = true
|
||||
@@ -766,6 +769,7 @@ func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
|
||||
|
||||
@@ -35,10 +35,10 @@ func (s *Server) execManager() {
|
||||
defer s.mu.Unlock()
|
||||
for _, t := range s.topics {
|
||||
subs := t.SubscribersCount()
|
||||
log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs)
|
||||
log.Tag(tagManager).With(t).Trace("- topic %s: %d subscribers", t.ID, subs)
|
||||
msgs, exists := messageCounts[t.ID]
|
||||
if subs == 0 && (!exists || msgs == 0) {
|
||||
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
|
||||
if t.Stale() && (!exists || msgs == 0) {
|
||||
log.Tag(tagManager).With(t).Trace("Deleting empty topic %s", t.ID)
|
||||
emptyTopics++
|
||||
delete(s.topics, t.ID)
|
||||
continue
|
||||
@@ -116,29 +116,30 @@ func (s *Server) pruneTokens() {
|
||||
}
|
||||
|
||||
func (s *Server) pruneAttachments() {
|
||||
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")
|
||||
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() {
|
||||
@@ -149,8 +150,10 @@ func (s *Server) pruneMessages() {
|
||||
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 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")
|
||||
|
||||
28
server/server_manager_test.go
Normal file
28
server/server_manager_test.go
Normal 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)
|
||||
}
|
||||
@@ -71,25 +71,19 @@ type matrixResponse struct {
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
// errMatrix represents an error when handing Matrix gateway messages
|
||||
type errMatrix struct {
|
||||
pushKey string
|
||||
err error
|
||||
// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages
|
||||
//
|
||||
// If the push key is set, the app server will remove it and will never send messages using the same
|
||||
// push key again, until the user repairs it.
|
||||
type errMatrixPushkeyRejected struct {
|
||||
rejectedPushKey string
|
||||
configuredBaseURL string
|
||||
}
|
||||
|
||||
func (e errMatrix) Error() string {
|
||||
if e.err != nil {
|
||||
return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error())
|
||||
}
|
||||
return fmt.Sprintf("message with push key %s rejected", e.pushKey)
|
||||
func (e errMatrixPushkeyRejected) Error() string {
|
||||
return fmt.Sprintf("push key must be prefixed with base URL, received push key: %s, configured base URL: %s", e.rejectedPushKey, e.configuredBaseURL)
|
||||
}
|
||||
|
||||
const (
|
||||
// matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest)
|
||||
// along with the request. The push key is only used if an error occurs down the line.
|
||||
matrixPushKeyHeader = "X-Matrix-Pushkey"
|
||||
)
|
||||
|
||||
// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new
|
||||
// HTTP request that looks like a normal ntfy request from it.
|
||||
//
|
||||
@@ -122,17 +116,16 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
|
||||
}
|
||||
pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316
|
||||
if !strings.HasPrefix(pushKey, baseURL+"/") {
|
||||
return nil, &errMatrix{pushKey: pushKey, err: wrapErrHTTP(errHTTPBadRequestMatrixPushkeyBaseURLMismatch, "received push key: %s, configured base URL: %s", pushKey, baseURL)}
|
||||
return nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL}
|
||||
}
|
||||
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
|
||||
if err != nil {
|
||||
return nil, &errMatrix{pushKey: pushKey, err: err}
|
||||
return nil, err
|
||||
}
|
||||
newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted
|
||||
if r.Header.Get("X-Forwarded-For") != "" {
|
||||
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
|
||||
}
|
||||
newRequest.Header.Set(matrixPushKeyHeader, pushKey)
|
||||
return newRequest, nil
|
||||
}
|
||||
|
||||
@@ -144,12 +137,6 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
|
||||
func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Matrix gateway error")
|
||||
return writeMatrixResponse(w, err.pushKey)
|
||||
}
|
||||
|
||||
// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter
|
||||
func writeMatrixSuccess(w http.ResponseWriter) error {
|
||||
return writeMatrixResponse(w, "")
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -19,7 +18,6 @@ func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "POST", newRequest.Method)
|
||||
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String())
|
||||
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey"))
|
||||
require.Equal(t, body, readAll(t, newRequest.Body))
|
||||
}
|
||||
|
||||
@@ -56,10 +54,10 @@ func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
|
||||
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
matrixErr, ok := err.(*errMatrix)
|
||||
matrixErr, ok := err.(*errMatrixPushkeyRejected)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "invalid request: push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.err.Error())
|
||||
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey)
|
||||
require.Equal(t, "push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.Error())
|
||||
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.rejectedPushKey)
|
||||
}
|
||||
|
||||
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
||||
@@ -71,9 +69,7 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
||||
|
||||
func TestMatrix_WriteMatrixError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
|
||||
v := newVisitor(newTestConfig(t), nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
|
||||
require.Nil(t, writeMatrixResponse(w, "https://ntfy.example.com/upABCDEFGHI?up=1"))
|
||||
require.Equal(t, 200, w.Result().StatusCode)
|
||||
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
@@ -16,6 +24,30 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context
|
||||
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vrate := v
|
||||
if rateVisitor := t.RateVisitor(); rateVisitor != nil {
|
||||
vrate = rateVisitor
|
||||
}
|
||||
r = withContext(r, map[contextKey]any{
|
||||
contextRateVisitor: vrate,
|
||||
contextTopic: t,
|
||||
})
|
||||
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
|
||||
return next(w, r, v)
|
||||
} else if !vrate.RequestAllowed() {
|
||||
return errHTTPTooManyRequestsLimitRequests
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if !s.config.EnableWeb {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
@@ -175,15 +188,16 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if err != nil {
|
||||
return err
|
||||
} else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" {
|
||||
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "customer or subscription not found")
|
||||
return errHTTPBadRequestBillingRequestInvalid.Wrap("customer or subscription not found")
|
||||
}
|
||||
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 {
|
||||
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription")
|
||||
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil {
|
||||
return errHTTPBadRequestBillingRequestInvalid.Wrap("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,28 +251,37 @@ 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
|
||||
} else if sub.Items == nil || len(sub.Items.Data) != 1 {
|
||||
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "no items, or more than one item")
|
||||
return errHTTPBadRequestBillingRequestInvalid.Wrap("no items, or more than one item")
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -400,6 +415,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
}
|
||||
|
||||
func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This tests incoming webhooks from Stripe to update a subscription:
|
||||
// - All Stripe columns are updated in the user table
|
||||
// - When downgrading, excess reservations are deleted, including messages and attachments in
|
||||
@@ -423,7 +440,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 +452,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 +474,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 +517,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 +565,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 +581,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 +635,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 +647,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 +666,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 +817,10 @@ const subscriptionUpdatedEventJSON = `
|
||||
"data": [
|
||||
{
|
||||
"price": {
|
||||
"id": "price_1234"
|
||||
"id": "price_1234",
|
||||
"recurring": {
|
||||
"interval": "year"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -818,7 +843,10 @@ const subscriptionDeletedEventJSON = `
|
||||
"data": [
|
||||
{
|
||||
"price": {
|
||||
"id": "price_1234"
|
||||
"id": "price_1234",
|
||||
"recurring": {
|
||||
"interval": "month"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestServer_PublishWithFirebase(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.KeepaliveInterval = time.Second
|
||||
s := newTestServer(t, c)
|
||||
@@ -122,6 +123,7 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAndSubscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
subscribeRR := httptest.NewRecorder()
|
||||
@@ -149,6 +151,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 +291,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())
|
||||
@@ -294,6 +299,7 @@ func TestServer_PublishNoCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.MinDelay = time.Second
|
||||
c.DelayedSenderInterval = 100 * time.Millisecond
|
||||
@@ -449,6 +455,7 @@ func TestServer_PublishWithNopCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAndPollSince(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
request(t, s, "PUT", "/mytopic", "test 1", nil)
|
||||
@@ -629,6 +636,7 @@ func TestServer_PollWithQueryFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_SubscribeWithQueryFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.KeepaliveInterval = 800 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
@@ -793,7 +801,27 @@ func TestServer_Auth_ViaQuery(t *testing.T) {
|
||||
require.Equal(t, 401, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_Auth_NonBasicHeader(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||
"Authorization": "WebPush not-supported",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||
"Authorization": "Bearer supported",
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
|
||||
response = request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||
"Authorization": "basic supported",
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_StatsResetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This tests the stats resetter for
|
||||
// - an anonymous user
|
||||
// - a user without a tier (treated like the same as the anonymous user)
|
||||
@@ -934,6 +962,8 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This tests that the daily message quota is prefilled originally from the database,
|
||||
// if the visitor is unknown
|
||||
|
||||
@@ -1006,15 +1036,29 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||
|
||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 65; i++ { // > 60
|
||||
for i := 0; i < 5; i++ { // > 3
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 10
|
||||
c.VisitorMessageDailyLimit = 4
|
||||
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 8; i++ { // 4
|
||||
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 60
|
||||
c.VisitorRequestLimitReplenish = time.Second
|
||||
@@ -1047,6 +1091,7 @@ func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
@@ -1105,7 +1150,15 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||
|
||||
// Register a UnifiedPush subscriber
|
||||
response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "up123456789012",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Publish message to topic
|
||||
response = request(t, s, "PUT", "/up123456789012?up=1", string(b), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
@@ -1114,7 +1167,8 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, b, b2)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", string(b), nil)
|
||||
// Retrieve and check published message
|
||||
response = request(t, s, "GET", "/up123456789012/json?poll=1", string(b), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m = toMessage(t, response.Body.String())
|
||||
require.Equal(t, "base64", m.Encoding)
|
||||
@@ -1129,7 +1183,15 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||
|
||||
// Register a UnifiedPush subscriber
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "mytopic",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Publish message to topic
|
||||
response = request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
@@ -1142,7 +1204,15 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||
|
||||
func TestServer_PublishUnifiedPushText(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
|
||||
|
||||
// Register a UnifiedPush subscriber
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "mytopic",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Publish UnifiedPush text message
|
||||
response = request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
@@ -1169,8 +1239,14 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
|
||||
|
||||
func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "mytopic", // Register first!
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
|
||||
|
||||
@@ -1180,6 +1256,14 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||
require.Equal(t, notification, m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 507, response.Code)
|
||||
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
|
||||
@@ -1197,9 +1281,12 @@ func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {
|
||||
notification := `{"message":"this is not really a Matrix message"}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 40019, err.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code)
|
||||
|
||||
notification = `this isn't even JSON'`
|
||||
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
|
||||
@@ -1209,9 +1296,7 @@ func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 500, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 50003, err.Code)
|
||||
require.Equal(t, 500, err.HTTPCode)
|
||||
require.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishActions_AndPoll(t *testing.T) {
|
||||
@@ -1257,7 +1342,24 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
||||
require.True(t, m.Time < time.Now().Unix()+31*60)
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) {
|
||||
// Publishing as JSON follows a different path. This ensures that rate
|
||||
// limiting works for this endpoint as well
|
||||
c := newTestConfig(t)
|
||||
c.VisitorMessageDailyLimit = 3
|
||||
s := newTestServer(t, c)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil)
|
||||
require.Equal(t, 429, response.Code)
|
||||
require.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailer := &testMailer{}
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.smtpSender = mailer
|
||||
@@ -1498,7 +1600,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)
|
||||
@@ -1513,6 +1615,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfig(t)
|
||||
@@ -1540,6 +1643,7 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
@@ -1807,6 +1911,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
count := 50000
|
||||
c := newTestConfig(t)
|
||||
c.TotalTopicLimit = 50001
|
||||
@@ -1886,6 +1991,210 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
|
||||
require.Equal(t, int64(2), account.Stats.Messages)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
|
||||
subscriber1Fn := func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
}
|
||||
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "subscriber1topic",
|
||||
}, subscriber1Fn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
|
||||
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
|
||||
subscriber2Fn := func(r *http.Request) {
|
||||
r.RemoteAddr = "8.7.7.1"
|
||||
}
|
||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
|
||||
// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the
|
||||
// GET request before is also counted towards the request limiter.
|
||||
for i := 0; i < 2; i++ {
|
||||
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Publish another 2 messages to "up012345678912" as visitor 9.9.9.9
|
||||
for i := 0; i < 2; i++ {
|
||||
rr := request(t, s, "PUT", "/up012345678912", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor!
|
||||
}
|
||||
rr = request(t, s, "PUT", "/up012345678912", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though
|
||||
// VisitorRequestLimitBurst is 3. That means it's working.
|
||||
|
||||
// Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter
|
||||
// by publishing another 3 requests from it.
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "PUT", "/some-other-topic", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/some-other-topic", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" 5 different UnifiedPush visitors
|
||||
for i := 0; i < 5; i++ {
|
||||
subscriberFn := func(r *http.Request) {
|
||||
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
|
||||
}
|
||||
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
// Publish 2 messages per topic
|
||||
for i := 0; i < 5; i++ {
|
||||
for j := 0; j < 2; j++ {
|
||||
rr := request(t, s, "PUT", fmt.Sprintf("/up12345678901%d?up=1", i), "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" 5 different UnifiedPush visitors
|
||||
for i := 0; i < 5; i++ {
|
||||
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) {
|
||||
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
// Publish 2 messages per topic
|
||||
for i := 0; i < 5; i++ {
|
||||
notification := fmt.Sprintf(`{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/up12345678901%d?up=1"}]}}`, i)
|
||||
for j := 0; j < 2; j++ {
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
|
||||
}
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 429, response.Code, notification)
|
||||
require.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" rate visitor
|
||||
subscriberFn := func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
}
|
||||
rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"rate-topics": "mytopic",
|
||||
}, subscriberFn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String())
|
||||
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
|
||||
|
||||
// Publish message, observe rate visitor tokens being decreased
|
||||
response := request(t, s, "POST", "/mytopic", "some message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
|
||||
require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value())
|
||||
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
|
||||
|
||||
// Expire visitor
|
||||
s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour)
|
||||
s.pruneVisitors()
|
||||
|
||||
// Publish message again, observe that rateVisitor is not used anymore and is reset
|
||||
response = request(t, s, "POST", "/mytopic", "some message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
|
||||
require.Nil(t, s.topics["mytopic"].rateVisitor)
|
||||
require.Nil(t, s.visitors["ip:1.2.3.4"])
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create some ACLs
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "test",
|
||||
MessageLimit: 5,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("ben", "test"))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite))
|
||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite))
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||
require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite))
|
||||
|
||||
// Set rate visitor as user "phil" on topic
|
||||
// - "reserved-for-phil": Allowed, because I am the owner
|
||||
// - "public_topic": Allowed, because it has read-write permissions for everyone
|
||||
// - "announcements": NOT allowed, because it has read-only permissions for everyone
|
||||
rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
|
||||
require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name)
|
||||
require.Nil(t, s.topics["announcements"].rateVisitor)
|
||||
|
||||
// Set rate visitor as user "ben" on topic
|
||||
// - "reserved-for-phil": NOT allowed, because I am not the owner
|
||||
// - "public_topic": Allowed, because it has read-write permissions for everyone
|
||||
// - "announcements": Allowed, because I have read-write permissions
|
||||
rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
|
||||
require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name)
|
||||
require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionReadWrite
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create some ACLs
|
||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
||||
|
||||
// Set rate visitor as ip:1.2.3.4 on topic
|
||||
// - "up123456789012": Allowed, because no ACLs and nobody owns the topic
|
||||
// - "announcements": NOT allowed, because it has read-only permissions for everyone
|
||||
rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String())
|
||||
require.Nil(t, s.topics["announcements"].rateVisitor)
|
||||
}
|
||||
|
||||
func newTestConfig(t *testing.T) *Config {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
@@ -1911,17 +2220,20 @@ func newTestServer(t *testing.T, config *Config) *Server {
|
||||
return server
|
||||
}
|
||||
|
||||
func request(t *testing.T, s *Server, method, url, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
func request(t *testing.T, s *Server, method, url, body string, headers map[string]string, fn ...func(r *http.Request)) *httptest.ResponseRecorder {
|
||||
rr := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
r, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.RemoteAddr = "9.9.9.9" // Used for tests
|
||||
r.RemoteAddr = "9.9.9.9" // Used for tests
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
s.handle(rr, req)
|
||||
for _, f := range fn {
|
||||
f(r)
|
||||
}
|
||||
s.handle(rr, r)
|
||||
return rr
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -59,6 +65,7 @@ type smtpSession struct {
|
||||
backend *smtpBackend
|
||||
conn *smtp.Conn
|
||||
topic string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -75,6 +82,7 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
func (s *smtpSession) Rcpt(to string) error {
|
||||
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 {
|
||||
@@ -86,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
|
||||
})
|
||||
@@ -120,7 +137,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readMailBody(msg)
|
||||
body, err := readMailBody(msg.Body, msg.Header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -158,7 +175,6 @@ func (s *smtpSession) publishMessage(m *message) error {
|
||||
if err != nil {
|
||||
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))
|
||||
@@ -171,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 {
|
||||
@@ -202,48 +221,52 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -348,6 +348,171 @@ what's up
|
||||
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) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
type topic struct {
|
||||
ID string
|
||||
subscribers map[int]*topicSubscriber
|
||||
mu sync.Mutex
|
||||
rateVisitor *visitor
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type topicSubscriber struct {
|
||||
@@ -44,6 +45,30 @@ func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
|
||||
return subscriberID
|
||||
}
|
||||
|
||||
func (t *topic) Stale() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.rateVisitor != nil && !t.rateVisitor.Stale() {
|
||||
return false
|
||||
}
|
||||
return len(t.subscribers) == 0
|
||||
}
|
||||
|
||||
func (t *topic) SetRateVisitor(v *visitor) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.rateVisitor = v
|
||||
}
|
||||
|
||||
func (t *topic) RateVisitor() *visitor {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.rateVisitor != nil && t.rateVisitor.Stale() {
|
||||
t.rateVisitor = nil
|
||||
}
|
||||
return t.rateVisitor
|
||||
}
|
||||
|
||||
// Unsubscribe removes the subscription from the list of subscribers
|
||||
func (t *topic) Unsubscribe(id int) {
|
||||
t.mu.Lock()
|
||||
@@ -77,8 +102,8 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||
|
||||
// SubscribersCount returns the number of subscribers to this topic
|
||||
func (t *topic) SubscribersCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.subscribers)
|
||||
}
|
||||
|
||||
@@ -88,12 +113,33 @@ func (t *topic) CancelSubscribers(exceptUserID string) {
|
||||
defer t.mu.Unlock()
|
||||
for _, s := range t.subscribers {
|
||||
if s.userID != exceptUserID {
|
||||
log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID)
|
||||
log.
|
||||
Tag(tagSubscribe).
|
||||
With(t).
|
||||
Fields(log.Context{
|
||||
"user_id": s.userID,
|
||||
}).
|
||||
Debug("Canceling subscriber %s", s.userID)
|
||||
s.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *topic) Context() log.Context {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
fields := map[string]any{
|
||||
"topic": t.ID,
|
||||
"topic_subscribers": len(t.subscribers),
|
||||
}
|
||||
if t.rateVisitor != nil {
|
||||
for k, v := range t.rateVisitor.Context() {
|
||||
fields["topic_rate_"+k] = v
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// subscribersCopy returns a shallow copy of the subscribers map
|
||||
func (t *topic) subscribersCopy() map[int]*topicSubscriber {
|
||||
t.mu.Lock()
|
||||
|
||||
@@ -45,10 +45,10 @@ type message struct {
|
||||
|
||||
func (m *message) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_topic": m.Topic,
|
||||
"message_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
@@ -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"`
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -16,6 +18,17 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
return value == "1" || value == "yes" || value == "true"
|
||||
}
|
||||
|
||||
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
|
||||
paramStr := readParam(r, names...)
|
||||
if paramStr != "" {
|
||||
params = make([]string, 0)
|
||||
for _, s := range util.SplitNoEmpty(paramStr, ",") {
|
||||
params = append(params, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func readParam(r *http.Request, names ...string) string {
|
||||
value := readHeaderParam(r, names...)
|
||||
if value != "" {
|
||||
@@ -85,3 +98,19 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T,
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func withContext(r *http.Request, ctx map[contextKey]any) *http.Request {
|
||||
c := r.Context()
|
||||
for k, v := range ctx {
|
||||
c = context.WithValue(c, k, v)
|
||||
}
|
||||
return r.WithContext(c)
|
||||
}
|
||||
|
||||
func fromContext[T any](r *http.Request, key contextKey) *T {
|
||||
t, ok := r.Context().Value(key).(*T)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("cannot find key %v in request context", key))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ func (v *visitor) Context() log.Context {
|
||||
func (v *visitor) contextNoLock() log.Context {
|
||||
info := v.infoLightNoLock()
|
||||
fields := log.Context{
|
||||
"visitor_id": visitorID(v.ip, v.user),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
@@ -330,9 +331,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(u.Stats.Messages, u.Stats.Emails, true)
|
||||
var messages, emails int64
|
||||
if u != nil {
|
||||
messages, emails = u.Stats.Messages, u.Stats.Emails
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
517
user/manager.go
517
user/manager.go
@@ -46,7 +46,260 @@ var (
|
||||
|
||||
// Manager-related queries
|
||||
const (
|
||||
createTablesQueriesNoTx = `
|
||||
createTablesQueries = `
|
||||
BEGIN;
|
||||
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_monthly_price_id TEXT,
|
||||
stripe_yearly_price_id TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
|
||||
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,
|
||||
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_interval 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 ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
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_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_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_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_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 = ?
|
||||
`
|
||||
selectTopicPermsQuery = `
|
||||
SELECT read, write
|
||||
FROM user_access a
|
||||
JOIN user u ON u.id = a.user_id
|
||||
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic
|
||||
ORDER BY u.user DESC
|
||||
`
|
||||
|
||||
insertUserQuery = `
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
selectUsernamesQuery = `
|
||||
SELECT user
|
||||
FROM user
|
||||
ORDER BY
|
||||
CASE role
|
||||
WHEN 'admin' THEN 1
|
||||
WHEN 'anonymous' THEN 3
|
||||
ELSE 2
|
||||
END, user
|
||||
`
|
||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
|
||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
|
||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||
|
||||
upsertUserAccessQuery = `
|
||||
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
|
||||
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
|
||||
ON CONFLICT (user_id, topic)
|
||||
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
|
||||
`
|
||||
selectUserAccessQuery = `
|
||||
SELECT topic, read, write
|
||||
FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY write DESC, read DESC, topic
|
||||
`
|
||||
selectUserReservationsQuery = `
|
||||
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
|
||||
FROM user_access a_user
|
||||
LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?)
|
||||
WHERE a_user.user_id = a_user.owner_user_id
|
||||
AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY a_user.topic
|
||||
`
|
||||
selectUserReservationsCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id
|
||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
selectUserReservationsOwnerQuery = `
|
||||
SELECT owner_user_id
|
||||
FROM user_access
|
||||
WHERE topic = ?
|
||||
AND user_id = owner_user_id
|
||||
`
|
||||
selectUserHasReservationQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id
|
||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
AND topic = ?
|
||||
`
|
||||
selectOtherAccessCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE (topic = ? OR ? LIKE topic)
|
||||
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
|
||||
`
|
||||
deleteAllAccessQuery = `DELETE FROM user_access`
|
||||
deleteUserAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
deleteTopicAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
||||
AND topic = ?
|
||||
`
|
||||
|
||||
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
||||
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?`
|
||||
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?`
|
||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
||||
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||
deleteExcessTokensQuery = `
|
||||
DELETE FROM user_token
|
||||
WHERE (user_id, token) NOT IN (
|
||||
SELECT user_id, token
|
||||
FROM user_token
|
||||
WHERE user_id = ?
|
||||
ORDER BY expires DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
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_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_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_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_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_monthly_price_id, stripe_yearly_price_id
|
||||
FROM tier
|
||||
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 = ?`
|
||||
deleteTierQuery = `DELETE FROM tier WHERE code = ?`
|
||||
|
||||
updateBillingQuery = `
|
||||
UPDATE user
|
||||
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 = 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!)
|
||||
migrate1To2CreateTablesQueries = `
|
||||
ALTER TABLE user RENAME TO user_old;
|
||||
CREATE TABLE IF NOT EXISTS tier (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
@@ -110,186 +363,9 @@ const (
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.stripe_customer_id = ?
|
||||
`
|
||||
selectTopicPermsQuery = `
|
||||
SELECT read, write
|
||||
FROM user_access a
|
||||
JOIN user u ON u.id = a.user_id
|
||||
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic
|
||||
ORDER BY u.user DESC
|
||||
`
|
||||
|
||||
insertUserQuery = `
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
selectUsernamesQuery = `
|
||||
SELECT user
|
||||
FROM user
|
||||
ORDER BY
|
||||
CASE role
|
||||
WHEN 'admin' THEN 1
|
||||
WHEN 'anonymous' THEN 3
|
||||
ELSE 2
|
||||
END, user
|
||||
`
|
||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
|
||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
|
||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||
|
||||
upsertUserAccessQuery = `
|
||||
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
|
||||
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
|
||||
ON CONFLICT (user_id, topic)
|
||||
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
|
||||
`
|
||||
selectUserAccessQuery = `
|
||||
SELECT topic, read, write
|
||||
FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY write DESC, read DESC, topic
|
||||
`
|
||||
selectUserReservationsQuery = `
|
||||
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
|
||||
FROM user_access a_user
|
||||
LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?)
|
||||
WHERE a_user.user_id = a_user.owner_user_id
|
||||
AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY a_user.topic
|
||||
`
|
||||
selectUserReservationsCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
selectUserHasReservationQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id
|
||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
AND topic = ?
|
||||
`
|
||||
selectOtherAccessCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE (topic = ? OR ? LIKE topic)
|
||||
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
|
||||
`
|
||||
deleteAllAccessQuery = `DELETE FROM user_access`
|
||||
deleteUserAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
deleteTopicAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
||||
AND topic = ?
|
||||
`
|
||||
|
||||
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
||||
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?`
|
||||
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?`
|
||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
||||
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||
deleteExcessTokensQuery = `
|
||||
DELETE FROM user_token
|
||||
WHERE (user_id, token) NOT IN (
|
||||
SELECT user_id, token
|
||||
FROM user_token
|
||||
WHERE user_id = ?
|
||||
ORDER BY expires DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
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 = ?
|
||||
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
|
||||
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
|
||||
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
|
||||
FROM tier
|
||||
WHERE stripe_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 = ?`
|
||||
deleteTierQuery = `DELETE FROM tier WHERE code = ?`
|
||||
|
||||
updateBillingQuery = `
|
||||
UPDATE user
|
||||
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
|
||||
WHERE user = ?
|
||||
`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 2
|
||||
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 = `
|
||||
ALTER TABLE user RENAME TO user_old;
|
||||
`
|
||||
migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old`
|
||||
migrate1To2InsertUserNoTx = `
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
@@ -304,11 +380,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 +892,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 +915,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 +941,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
|
||||
@@ -943,6 +1032,24 @@ func (a *Manager) ReservationsCount(username string) (int64, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ReservationOwner returns user ID of the user that owns this topic, or an
|
||||
// empty string if it's not owned by anyone
|
||||
func (a *Manager) ReservationOwner(topic string) (string, error) {
|
||||
rows, err := a.db.Query(selectUserReservationsOwnerQuery, topic)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return "", nil
|
||||
}
|
||||
var ownerUserID string
|
||||
if err := rows.Scan(&ownerUserID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ownerUserID, nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password
|
||||
func (a *Manager) ChangePassword(username, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
||||
@@ -1134,7 +1241,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 +1249,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 +1269,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 +1307,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 +1317,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 +1340,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 +1421,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 +1461,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{}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
}
|
||||
|
||||
803
web/package-lock.json
generated
803
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
// During web development, you may change values here for rapid testing.
|
||||
|
||||
var config = {
|
||||
base_url: window.location.origin, // Set this to "https://127.0.0.1" to test against a different server
|
||||
base_url: "https://127.0.0.1", // to test against a different server
|
||||
app_root: "/app",
|
||||
enable_login: true,
|
||||
enable_signup: true,
|
||||
|
||||
281
web/public/static/langs/ar.json
Normal file
281
web/public/static/langs/ar.json
Normal 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": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر."
|
||||
}
|
||||
@@ -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>."
|
||||
}
|
||||
|
||||
@@ -187,5 +187,158 @@
|
||||
"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 se",
|
||||
"signup_form_username": "Uživatelské jméno",
|
||||
"signup_form_toggle_password_visibility": "Přepnout viditelnost hesla",
|
||||
"signup_already_have_account": "Už máte účet? Přihlašte se!",
|
||||
"signup_error_username_taken": "Uživatelské jméno {{username}} je již obsazeno",
|
||||
"signup_error_creation_limit_reached": "Dosažen limit pro vytvoření účtu",
|
||||
"login_title": "Přihlaste se do svého ntfy účtu",
|
||||
"login_form_button_submit": "Přihlásit se",
|
||||
"login_link_signup": "Zaregistrovat se",
|
||||
"login_disabled": "Přihlašování je zakázáno",
|
||||
"action_bar_account": "Účet",
|
||||
"action_bar_reservation_add": "Rezervovat 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í název pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat témata s komplikovanými jmény.",
|
||||
"action_bar_change_display_name": "Změnit zobrazovaný název",
|
||||
"action_bar_sign_in": "Přihlásit se",
|
||||
"alert_not_supported_context_description": "Oznámení jsou podporována pouze přes HTTPS. Toto je limitace <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_title": "Změnit zobrazovaný název",
|
||||
"account_basics_password_title": "Heslo",
|
||||
"account_basics_password_dialog_title": "Změna hesla",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Téma již rezervováno",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Generovat název",
|
||||
"account_delete_dialog_description": "Dojde k trvalému odstranění vašeho účtu včetně všech dat uložených na serveru. Po smazání bude vaše uživatelské jméno po dobu 7 dnů nedostupné. Pokud opravdu chcete pokračovat, potvrďte prosím své heslo.",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(s úrovní {{tier}})",
|
||||
"account_basics_tier_admin": "Administrátor",
|
||||
"account_basics_tier_basic": "Základní",
|
||||
"account_basics_tier_free": "Zdarma",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(žádná úroveň)",
|
||||
"account_basics_tier_upgrade_button": "Přejít na verzi Pro",
|
||||
"account_upgrade_dialog_cancel_warning": "Vaše <strong>předplatné se tímto zruší</strong> a váš účet se k datu {{date}} degraduje na nižší úroveň. K tomuto datu budou <strong>smazány</strong> rezervace témat i zprávy uložené v mezipaměti serveru.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Před změnou úrovně <strong>odstraňte alespoň {{počet}} rezervací</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
|
||||
"reservation_delete_dialog_description": "Odstraněním rezervace se vzdáte vlastnictví tématu a umožníte ostatním, aby si ho rezervovali. Stávající zprávy a přílohy si můžete ponechat nebo je odstranit.",
|
||||
"account_tokens_description": "Při publikování a odběru prostřednictvím rozhraní ntfy API používejte přístupové tokeny, abyste nemuseli odesílat přihlašovací údaje k účtu. Více informací najdete v <Link>dokumentaci</Link>.",
|
||||
"account_tokens_table_copied_to_clipboard": "Přístupový token zkopírován",
|
||||
"account_tokens_table_last_origin_tooltip": "Z IP adresy {{ip}}, klikněte pro vyhledání",
|
||||
"account_tokens_dialog_button_cancel": "Zrušit",
|
||||
"account_tokens_dialog_expires_never": "Token nikdy nevyprší",
|
||||
"account_tokens_delete_dialog_description": "Před odstraněním přístupového tokenu se ujistěte, že jej aktivně nepoužívají žádné aplikace ani skripty. <strong>Tuto akci nelze vrátit zpět</strong>.",
|
||||
"prefs_users_description_no_sync": "Uživatelé a hesla nejsou synchronizováni s vaším účtem.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Nelze odstranit ani upravit přihlášeného uživatele",
|
||||
"prefs_reservations_title": "Rezervovaná témata",
|
||||
"prefs_reservations_description": "Zde si můžete rezervovat názvy témat pro osobní použití. Rezervací tématu získáte vlastnické právo k tématu a můžete definovat přístupová práva pro ostatní uživatele k tématu.",
|
||||
"prefs_reservations_table_click_to_subscribe": "Kliknutím se přihlásíte k odběru",
|
||||
"prefs_reservations_dialog_description": "Rezervací tématu získáte vlastnictví tématu a můžete definovat přístupová oprávnění pro ostatní uživatele.",
|
||||
"prefs_reservations_dialog_access_label": "Přístup",
|
||||
"reservation_delete_dialog_action_keep_title": "Zachovat zprávy a přílohy v mezipaměti",
|
||||
"signup_disabled": "Přihlášení je zakázáno",
|
||||
"display_name_dialog_placeholder": "Zobrazovaný název",
|
||||
"reserve_dialog_checkbox_label": "Rezervace tématu a nastavení přístupu",
|
||||
"account_basics_title": "Účet",
|
||||
"account_basics_username_title": "Uživatelské jméno",
|
||||
"account_basics_username_description": "Hej, to jsi ty ❤",
|
||||
"account_basics_username_admin_tooltip": "Jste správce",
|
||||
"account_basics_password_description": "Změna hesla k účtu",
|
||||
"account_basics_password_dialog_current_password_label": "Současné heslo",
|
||||
"account_basics_password_dialog_new_password_label": "Nové heslo",
|
||||
"account_basics_password_dialog_confirm_password_label": "Potvrzení hesla",
|
||||
"account_basics_password_dialog_button_submit": "Změnit heslo",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Nesprávné heslo",
|
||||
"account_usage_title": "Použití",
|
||||
"account_usage_of_limit": "z {{limit}}",
|
||||
"account_usage_unlimited": "Neomezeně",
|
||||
"account_usage_limits_reset_daily": "Limity používání se resetují denně o půlnoci (UTC)",
|
||||
"account_basics_tier_title": "Typ účtu",
|
||||
"account_basics_tier_description": "Úroveň oprávnění vašeho účtu",
|
||||
"account_basics_tier_change_button": "Změnit",
|
||||
"account_basics_tier_paid_until": "Předplatné zaplaceno do {{date}} a bude automaticky obnoveno",
|
||||
"account_basics_tier_payment_overdue": "Vaše platba je po splatnosti. Aktualizujte prosím svůj způsob platby, jinak bude váš účet brzy degradován.",
|
||||
"account_basics_tier_canceled_subscription": "Vaše předplatné bylo zrušeno a ke dni {{date}} bude převedeno na bezplatný účet.",
|
||||
"account_basics_tier_manage_billing_button": "Správa vyúčtování",
|
||||
"account_usage_messages_title": "Zveřejněné zprávy",
|
||||
"account_usage_emails_title": "Odeslané e-maily",
|
||||
"account_usage_reservations_title": "Rezervovaná témata",
|
||||
"account_usage_reservations_none": "Žádná rezervovaná témata pro tento účet",
|
||||
"account_usage_attachment_storage_title": "Úložiště příloh",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} na soubor, maže se po {{expiry}}",
|
||||
"account_usage_basis_ip_description": "Statistiky a limity používání tohoto účtu jsou založeny na vaší IP adrese, takže mohou být sdíleny s ostatními uživateli. Výše uvedené limity jsou přibližné a vycházejí ze stávajících limitů.",
|
||||
"account_usage_cannot_create_portal_session": "Nelze otevřít portál pro fakturaci",
|
||||
"account_delete_title": "Odstranit účet",
|
||||
"account_delete_description": "Trvale odstranit účet",
|
||||
"account_delete_dialog_label": "Heslo",
|
||||
"account_delete_dialog_button_cancel": "Zrušit",
|
||||
"account_delete_dialog_button_submit": "Trvale odstranit účet",
|
||||
"account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.",
|
||||
"account_upgrade_dialog_title": "Změna úrovně účtu",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prohlášení</strong>: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně účtován nebo vrácen v následující faktuře. Další fakturu obdržíte až na konci dalšího zúčtovacího období.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, <strong>odstraňte alespoň jednu rezervaci</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} rezervovaných témat",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} denních zpráv",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} denních e-mailů",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na soubor",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný prostor",
|
||||
"account_upgrade_dialog_tier_selected_label": "Vybráno",
|
||||
"account_upgrade_dialog_tier_current_label": "Současné",
|
||||
"account_upgrade_dialog_button_cancel": "Zrušit",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Zaregistrovat se nyní",
|
||||
"account_upgrade_dialog_button_pay_now": "Zaplatit a předplatit si",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Zrušit předplatné",
|
||||
"account_upgrade_dialog_button_update_subscription": "Aktualizovat předplatné",
|
||||
"account_tokens_title": "Přístupové tokeny",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_last_access_header": "Poslední přístup",
|
||||
"account_tokens_table_expires_header": "Vyprší",
|
||||
"account_tokens_table_never_expires": "Nikdy nevyprší",
|
||||
"account_tokens_table_current_session": "Současná relace prohlížeče",
|
||||
"account_tokens_table_copy_to_clipboard": "Kopírování do schránky",
|
||||
"account_tokens_table_label_header": "Popisek",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
|
||||
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
|
||||
"account_tokens_dialog_title_create": "Vytvoření přístupového tokenu",
|
||||
"account_tokens_dialog_title_edit": "Úprava přístupového tokenu",
|
||||
"account_tokens_dialog_title_delete": "Odstranění přístupového tokenu",
|
||||
"account_tokens_dialog_label": "Popisek, např. Radarr notifications",
|
||||
"account_tokens_dialog_button_create": "Vytvořit token",
|
||||
"account_tokens_dialog_button_update": "Aktualizovat token",
|
||||
"account_tokens_dialog_expires_label": "Platnost přístupového tokenu vyprší za",
|
||||
"account_tokens_dialog_expires_unchanged": "Ponechat datum vypršení platnosti beze změny",
|
||||
"account_tokens_dialog_expires_x_hours": "Token vyprší za {{hours}} hodin",
|
||||
"account_tokens_dialog_expires_x_days": "Token vyprší za {{days}} dní",
|
||||
"account_tokens_delete_dialog_title": "Odstranění přístupového tokenu",
|
||||
"account_tokens_delete_dialog_submit_button": "Trvale odstranit token",
|
||||
"prefs_reservations_limit_reached": "Dosáhli jste limitu rezervovaných témat.",
|
||||
"prefs_reservations_add_button": "Přidat rezervované téma",
|
||||
"prefs_reservations_edit_button": "Upravit přístup k tématu",
|
||||
"prefs_reservations_delete_button": "Resetovat přístup k tématu",
|
||||
"prefs_reservations_table": "Tabulka rezervovaných témat",
|
||||
"prefs_reservations_table_topic_header": "Téma",
|
||||
"prefs_reservations_table_access_header": "Přístup",
|
||||
"prefs_reservations_table_everyone_deny_all": "Pouze já mohu publikovat a přihlásit se k odběru",
|
||||
"prefs_reservations_table_everyone_read_only": "Mohu publikovat a přihlásit se k odběru, kdokoli se může přihlásit k odběru",
|
||||
"prefs_reservations_table_everyone_write_only": "Mohu publikovat a přihlásit se k odběru, kdokoli může publikovat",
|
||||
"prefs_reservations_table_everyone_read_write": "Kdokoli může publikovat a přihlásit se k odběru",
|
||||
"prefs_reservations_table_not_subscribed": "Odběr není přihlášen",
|
||||
"prefs_reservations_dialog_title_add": "Rezervovat téma",
|
||||
"prefs_reservations_dialog_title_edit": "Úprava rezervovaného tématu",
|
||||
"prefs_reservations_dialog_title_delete": "Odstranění rezervovaného tématu",
|
||||
"prefs_reservations_dialog_topic_label": "Téma",
|
||||
"reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.",
|
||||
"reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti",
|
||||
"reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.",
|
||||
"reservation_delete_dialog_submit_button": "Odstranit rezervaci"
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
|
||||
"publish_dialog_delay_label": "Verzögerung",
|
||||
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@example.com",
|
||||
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
|
||||
"publish_dialog_chip_click_label": "Klick-URL",
|
||||
"publish_dialog_button_cancel_sending": "Senden abbrechen",
|
||||
"publish_dialog_drop_file_here": "Datei hierher ziehen",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -187,5 +187,58 @@
|
||||
"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",
|
||||
"display_name_dialog_title": "Cambiar el nombre para mostrar",
|
||||
"display_name_dialog_description": "Establezca un nombre alternativo para un tópico que se muestra en la lista de suscripciones. Esto ayuda a identificar más fácilmente los temas con nombres complicados.",
|
||||
"display_name_dialog_placeholder": "Nombre para mostrar",
|
||||
"account_basics_username_admin_tooltip": "Eres Administrador",
|
||||
"account_basics_password_description": "Cambiar la contraseña de tu cuenta",
|
||||
"account_basics_password_dialog_confirm_password_label": "Confirmar contraseña",
|
||||
"account_basics_password_dialog_button_submit": "Cambiar contraseña",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta",
|
||||
"account_usage_unlimited": "Ilimitado",
|
||||
"account_usage_title": "Uso",
|
||||
"account_usage_of_limit": "de {{límite}}",
|
||||
"account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)",
|
||||
"account_basics_tier_description": "Nivel de poder de tu cuenta",
|
||||
"account_basics_tier_admin": "Administrador",
|
||||
"alert_not_supported_context_description": "Las notificaciones sólo se admiten a través de HTTPS. Esta es una limitante de la <mdnLink>API de notificaciones</mdnLink> .",
|
||||
"reserve_dialog_checkbox_label": "Reservar tópico y configurar el acceso",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Generar nombre",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Tópico ya reservado",
|
||||
"account_basics_title": "Cuenta",
|
||||
"account_basics_username_title": "Nombre de usuario",
|
||||
"account_basics_username_description": "Hey, ese eres tú ❤",
|
||||
"account_basics_password_title": "Contraseña",
|
||||
"account_basics_password_dialog_title": "Cambiar contraseña",
|
||||
"account_basics_password_dialog_current_password_label": "Contraseña actual",
|
||||
"account_basics_password_dialog_new_password_label": "Contraseña nueva"
|
||||
}
|
||||
|
||||
@@ -187,5 +187,158 @@
|
||||
"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": "Confirmation du mot de passe",
|
||||
"signup_disabled": "L'inscription est désactivée",
|
||||
"signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé",
|
||||
"signup_error_creation_limit_reached": "Limite de création de comptes atteinte",
|
||||
"login_title": "Se connecter à son compte Ntfy",
|
||||
"login_form_button_submit": "Connexion",
|
||||
"login_link_signup": "S'inscrire",
|
||||
"login_disabled": "La connection est 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",
|
||||
"signup_form_toggle_password_visibility": "Afficher le mot de passe",
|
||||
"action_bar_change_display_name": "Changer le nom affiché",
|
||||
"prefs_reservations_table_click_to_subscribe": "Cliquer pour s'abonner",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Impossible d'éditer ou de supprimer le jeton de la session actuelle",
|
||||
"account_tokens_dialog_button_cancel": "Annuler",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Impossible de supprimer ou de modifier un utilisateur connecté",
|
||||
"prefs_users_description_no_sync": "Les utilisateurs et les mots de passe ne sont pas synchronisés avec votre compte.",
|
||||
"account_tokens_dialog_button_update": "Mettre à jour un jeton",
|
||||
"nav_upgrade_banner_description": "Réservation de sujets, plus de messages et d'emails, et des pièces jointes plus larges",
|
||||
"display_name_dialog_description": "Mettre un nom supplémentaire pour un sujet qui est affiché dans la liste des abonnements. Cela aide à identifier plus facilement les sujets ayant des noms compliqués.",
|
||||
"account_usage_basis_ip_description": "Les statistiques d'utilisation et les limites pour ce compte sont basées sur votre adresse IP, donc elles peuvent être partagées avec d'autres utilisateurs. Les limites affichées plus haut sont approximativement basées sur les limites de débit existantes.",
|
||||
"action_bar_reservation_add": "Réserver un sujet",
|
||||
"action_bar_reservation_edit": "Changer la réservation",
|
||||
"action_bar_reservation_delete": "Supprimer la réservation",
|
||||
"action_bar_reservation_limit_reached": "Limite atteinte",
|
||||
"nav_upgrade_banner_label": "Passer à ntfy Pro",
|
||||
"display_name_dialog_title": "Changer le nom affiché",
|
||||
"reserve_dialog_checkbox_label": "Réserver un sujet et en configurer l'accès",
|
||||
"display_name_dialog_placeholder": "Nom affiché",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Générer un nom",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Sujet déjà réservé",
|
||||
"account_basics_title": "Compte",
|
||||
"account_basics_username_title": "Nom d'utilisateur",
|
||||
"account_basics_username_description": "Hé, c'est toi ❤",
|
||||
"account_basics_username_admin_tooltip": "Vous êtes Administrateur",
|
||||
"account_basics_password_title": "Mot de passe",
|
||||
"account_basics_password_description": "Changer le mot de passe de votre compte",
|
||||
"account_basics_password_dialog_title": "Changer le mot de passe",
|
||||
"account_basics_password_dialog_current_password_label": "Mot de passe actuel",
|
||||
"account_basics_password_dialog_new_password_label": "Nouveau mot de passe",
|
||||
"account_basics_password_dialog_confirm_password_label": "Confirmer le mot de passe",
|
||||
"account_basics_password_dialog_button_submit": "Changer le mot de passe",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Mot de passe incorrect",
|
||||
"account_usage_title": "Utilisation",
|
||||
"account_usage_of_limit": "sur {{limit}}",
|
||||
"account_usage_unlimited": "Illimité",
|
||||
"account_usage_limits_reset_daily": "Les limites d'utilisation sont réinitialisées chaque jour à minuit (UTC)",
|
||||
"account_basics_tier_title": "Type de compte",
|
||||
"account_basics_tier_description": "Le niveau de puissance de votre compte",
|
||||
"account_basics_tier_admin": "Administrateur",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(avec le tarif {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(pas de tarif)",
|
||||
"account_basics_tier_free": "Gratuit",
|
||||
"account_basics_tier_upgrade_button": "Passer à Pro",
|
||||
"account_basics_tier_change_button": "Changer",
|
||||
"account_basics_tier_paid_until": "Abonnement payé jusqu'à {{date}}, et va être automatiquement renouvelé",
|
||||
"account_basics_tier_canceled_subscription": "Votre abonnement a été annulé et va être rétrogradé vers un compte gratuit le {{date}}.",
|
||||
"account_basics_tier_manage_billing_button": "Gérer la facturation",
|
||||
"account_usage_messages_title": "Messages publiés",
|
||||
"account_usage_emails_title": "Emails envoyés",
|
||||
"account_usage_reservations_title": "Sujets réservés",
|
||||
"account_usage_reservations_none": "Pas de sujet réservé pour ce compte",
|
||||
"account_usage_attachment_storage_title": "Stockage des pièces jointes",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} par fichier, supprimé après {{expiry}}",
|
||||
"account_usage_cannot_create_portal_session": "Impossible d'ouvrir le portail de facturation",
|
||||
"account_delete_title": "Supprimer le compte",
|
||||
"account_delete_description": "Supprimer définitivement votre compte",
|
||||
"account_basics_tier_basic": "Basique",
|
||||
"account_delete_dialog_description": "Cela supprimera définitivement votre compte, ainsi que toutes les données qui sont stockées sur le serveur. Après suppression, votre nom d'utilisateur sera indisponible pendant 7 jours. Si vous voulez vraiment faire cela, veuillez le confirmer en mettant votre mot de passe dans le champ ci-dessous.",
|
||||
"account_delete_dialog_label": "Mot de passe",
|
||||
"account_delete_dialog_button_cancel": "Annuler",
|
||||
"account_delete_dialog_button_submit": "Supprimer définitivement le compte",
|
||||
"account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.",
|
||||
"account_upgrade_dialog_title": "Changer le tarif du compte",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Facturation</strong> : Lors d'un changement entre un plan payant et un autre, la différence de prix sera créditée ou remboursée sur la prochaine facture. Vous ne recevrez pas d'autre facture avant la fin de la prochaine période de facturation.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} sujets réservés",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} messages journaliers",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} emails journaliers",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} par fichier",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stockage total",
|
||||
"account_upgrade_dialog_tier_selected_label": "Sélectionné",
|
||||
"account_upgrade_dialog_tier_current_label": "Actuel",
|
||||
"account_upgrade_dialog_button_cancel": "Annuler",
|
||||
"account_upgrade_dialog_button_redirect_signup": "S'inscrire maintenant",
|
||||
"account_upgrade_dialog_button_pay_now": "Payer maintenant et s'abonner",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Annuler l'abonnement",
|
||||
"account_upgrade_dialog_button_update_subscription": "Mettre à jour l'abonnement",
|
||||
"account_tokens_title": "Jetons d'accès",
|
||||
"account_tokens_table_token_header": "Jeton",
|
||||
"account_tokens_table_label_header": "Étiquette",
|
||||
"account_tokens_table_last_access_header": "Dernier accès",
|
||||
"account_tokens_table_expires_header": "Expire",
|
||||
"account_tokens_table_never_expires": "N'expire jamais",
|
||||
"account_tokens_table_current_session": "Session de navigation actuelle",
|
||||
"account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier",
|
||||
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
|
||||
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
|
||||
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
|
||||
"account_tokens_dialog_title_create": "Créer un jeton d'accès",
|
||||
"account_tokens_dialog_title_edit": "Modifier le jeton d'accès",
|
||||
"account_tokens_dialog_title_delete": "Supprimer le jeton d'accès",
|
||||
"account_tokens_dialog_label": "Étiquette, par ex. Notifications Radarr",
|
||||
"account_tokens_dialog_button_create": "Créer un jeton",
|
||||
"account_tokens_dialog_expires_label": "Le jeton d'accès expire dans",
|
||||
"account_tokens_dialog_expires_unchanged": "Laisser la date d'expiration inchangée",
|
||||
"account_tokens_dialog_expires_x_hours": "Le jeton expire dans {{hours}} heures",
|
||||
"account_tokens_dialog_expires_x_days": "Le jeton expire dans {{days}} jours",
|
||||
"account_tokens_dialog_expires_never": "Le jeton n'expire jamais",
|
||||
"account_tokens_delete_dialog_title": "Supprimer le jeton d'accès",
|
||||
"account_tokens_delete_dialog_submit_button": "Supprimer définitivement le jeton",
|
||||
"prefs_reservations_title": "Sujets réservés",
|
||||
"prefs_reservations_limit_reached": "Vous avez atteint votre limite de réservation de sujets.",
|
||||
"prefs_reservations_add_button": "Ajouter un sujet réservé",
|
||||
"prefs_reservations_edit_button": "Modifier l'accès d'un sujet",
|
||||
"prefs_reservations_delete_button": "Réinitialiser l'accès d'un sujet",
|
||||
"prefs_reservations_table": "Tableau des sujets réservés",
|
||||
"prefs_reservations_table_topic_header": "Sujet",
|
||||
"prefs_reservations_table_access_header": "Accès",
|
||||
"prefs_reservations_table_everyone_deny_all": "Seulement moi peut publier et m'abonner",
|
||||
"prefs_reservations_table_everyone_read_only": "Je peux publier et m'abonner, tout le monde peut s'abonner",
|
||||
"prefs_reservations_table_everyone_write_only": "Je peux publier et m'abonner, tout le monde peut publier",
|
||||
"prefs_reservations_table_everyone_read_write": "Tout le monde peut publier et s'abonner",
|
||||
"prefs_reservations_table_not_subscribed": "Pas abonné",
|
||||
"prefs_reservations_dialog_title_add": "Réserver un sujet",
|
||||
"prefs_reservations_dialog_title_edit": "Modifier un sujet réservé",
|
||||
"prefs_reservations_dialog_title_delete": "Supprimé un sujet réservé",
|
||||
"prefs_reservations_dialog_description": "Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.",
|
||||
"prefs_reservations_dialog_topic_label": "Sujet",
|
||||
"prefs_reservations_dialog_access_label": "Accès",
|
||||
"reservation_delete_dialog_description": "Supprimer un sujet réservé abandonne la propriété sur le sujet et permet aux autres de le réserver. Vous pouvez garder ou supprimer les messages et pièces jointes existantes.",
|
||||
"reservation_delete_dialog_action_keep_title": "Garder les messages et pièces jointes mises en cache",
|
||||
"reservation_delete_dialog_action_keep_description": "Les messages et pièces jointes qui sont dans le cache du serveur deviendront visibles publiquement pour les personnes ayant connaissance du nom du sujet.",
|
||||
"reservation_delete_dialog_action_delete_title": "Supprimer les messages et pièces jointes mises en cache",
|
||||
"reservation_delete_dialog_action_delete_description": "Les messages et pièces jointes mises en cache seront définitivement supprimées. Cette action ne peut pas être annulée.",
|
||||
"reservation_delete_dialog_submit_button": "Supprimer un sujet réservé",
|
||||
"alert_not_supported_context_description": "Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la <mdnLink>Notifications API</mdnLink>.",
|
||||
"account_basics_tier_payment_overdue": "Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.",
|
||||
"account_upgrade_dialog_cancel_warning": "Cela va <strong>annuler votre abonnement</strong> et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur <strong>seront supprimés</strong>.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins un sujet réservé</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
|
||||
"account_tokens_description": "Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la <Link>documentation</Link> pour en savoir plus.",
|
||||
"account_tokens_delete_dialog_description": "Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. <strong>Cette action ne peut pas être annulée</strong>.",
|
||||
"prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
"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"
|
||||
"message_bar_error_publishing": "Eroare la publicarea notificării",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Setări"
|
||||
}
|
||||
|
||||
@@ -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ı açı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."
|
||||
}
|
||||
|
||||
@@ -187,5 +187,158 @@
|
||||
"publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
|
||||
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
|
||||
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
|
||||
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)"
|
||||
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)",
|
||||
"account_usage_basis_ip_description": "此帐户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
|
||||
"account_usage_cannot_create_portal_session": "无法打开计费门户",
|
||||
"account_delete_title": "删除帐户",
|
||||
"account_delete_description": "永久删除您的帐户",
|
||||
"signup_error_username_taken": "用户名 {{username}} 已被占用",
|
||||
"signup_error_creation_limit_reached": "已达到帐户创建限制",
|
||||
"login_title": "请登录你的 ntfy 帐户",
|
||||
"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": "登录",
|
||||
"action_bar_sign_up": "注册",
|
||||
"nav_button_account": "帐户",
|
||||
"nav_upgrade_banner_label": "升级到 ntfy Pro",
|
||||
"nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件",
|
||||
"alert_not_supported_context_description": "通知仅支持 HTTPS。这是 <mdnLink>Notifications API</mdnLink> 的限制。",
|
||||
"display_name_dialog_title": "更改显示名称",
|
||||
"display_name_dialog_description": "为订阅列表中显示的主题设置一个替代名称。这有助于更轻松地识别名称复杂的主题。",
|
||||
"display_name_dialog_placeholder": "显示名称",
|
||||
"reserve_dialog_checkbox_label": "保留主题并配置访问",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名称",
|
||||
"account_basics_username_description": "嘿,那是你 ❤",
|
||||
"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_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置",
|
||||
"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": "升级到专业版",
|
||||
"account_basics_tier_change_button": "改变",
|
||||
"account_basics_tier_paid_until": "订阅已支付至 {{date}},并将自动续订",
|
||||
"account_basics_tier_manage_billing_button": "管理计费",
|
||||
"account_usage_messages_title": "已发布消息",
|
||||
"account_usage_emails_title": "已发送电子邮件",
|
||||
"account_usage_reservations_title": "保留主题",
|
||||
"account_usage_reservations_none": "此帐户没有保留主题",
|
||||
"account_usage_attachment_storage_title": "附件存储",
|
||||
"account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除",
|
||||
"account_upgrade_dialog_button_pay_now": "立即付款并订阅",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "取消订阅",
|
||||
"account_upgrade_dialog_button_update_subscription": "更新订阅",
|
||||
"account_tokens_dialog_title_create": "创建访问令牌",
|
||||
"account_tokens_dialog_title_edit": "编辑访问令牌",
|
||||
"account_tokens_dialog_title_delete": "删除访问令牌",
|
||||
"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_x_days": "令牌在 {{days}} 天后过期",
|
||||
"account_tokens_dialog_expires_never": "令牌永不过期",
|
||||
"account_tokens_delete_dialog_title": "删除访问令牌",
|
||||
"account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 <strong>此操作无法撤消</strong>。",
|
||||
"account_tokens_delete_dialog_submit_button": "永久删除令牌",
|
||||
"prefs_users_description_no_sync": "用户和密码不会同步到您的帐户。",
|
||||
"prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户",
|
||||
"prefs_reservations_title": "保留主题",
|
||||
"prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。",
|
||||
"prefs_reservations_limit_reached": "您已达到保留主题限制。",
|
||||
"prefs_reservations_add_button": "添加保留主题",
|
||||
"prefs_reservations_edit_button": "编辑主题访问",
|
||||
"prefs_reservations_delete_button": "重置主题访问",
|
||||
"prefs_reservations_table": "保留主题表格",
|
||||
"prefs_reservations_table_topic_header": "主题",
|
||||
"prefs_reservations_table_access_header": "访问",
|
||||
"prefs_reservations_table_everyone_deny_all": "只有我可以发布和订阅",
|
||||
"prefs_reservations_table_everyone_read_only": "我可以发布和订阅,每个人都可以订阅",
|
||||
"prefs_reservations_table_everyone_write_only": "我可以发布和订阅,每个人都可以发布",
|
||||
"prefs_reservations_table_everyone_read_write": "每个人都可以发布和订阅",
|
||||
"prefs_reservations_table_not_subscribed": "未订阅",
|
||||
"prefs_reservations_table_click_to_subscribe": "点击以订阅",
|
||||
"prefs_reservations_dialog_title_add": "保留主题",
|
||||
"prefs_reservations_dialog_title_edit": "编辑保留主题",
|
||||
"prefs_reservations_dialog_title_delete": "删除主题保留",
|
||||
"prefs_reservations_dialog_description": "保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。",
|
||||
"prefs_reservations_dialog_topic_label": "主题",
|
||||
"prefs_reservations_dialog_access_label": "访问",
|
||||
"reservation_delete_dialog_description": "删除保留会放弃对该主题的所有权,并允许其他人保留它。您可以保留或删除现有邮件和附件。",
|
||||
"reservation_delete_dialog_action_keep_title": "保留缓存的邮件和附件",
|
||||
"reservation_delete_dialog_action_keep_description": "缓存在服务器上的消息和附件将对知道主题名称的人公开可见。",
|
||||
"reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件",
|
||||
"reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。",
|
||||
"reservation_delete_dialog_submit_button": "删除保留",
|
||||
"account_delete_dialog_description": "这将永久删除您的帐户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。",
|
||||
"account_delete_dialog_label": "密码",
|
||||
"account_delete_dialog_button_cancel": "取消",
|
||||
"account_delete_dialog_button_submit": "永久删除帐户",
|
||||
"account_delete_dialog_billing_warning": "删除您的帐户也会立即取消您的计费订阅。您将无法再访问计费仪表板。",
|
||||
"account_upgrade_dialog_title": "更改帐户等级",
|
||||
"account_upgrade_dialog_cancel_warning": "这将<strong>取消您的订阅</strong>,并在 {{date}} 降级您的帐户。在那一天,主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。",
|
||||
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付费计划之间切换时,差价将在下一次计费时收取或退还。在下一个计费周期结束之前,您不会收到另一张收据。",
|
||||
"account_upgrade_dialog_reservations_warning_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 1 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
||||
"account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 {{count}} 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} 条保留主题",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} 条每日消息",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} 条每日邮件",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} 每个文件",
|
||||
"signup_form_confirm_password": "确认密码",
|
||||
"signup_form_button_submit": "注册",
|
||||
"signup_form_toggle_password_visibility": "切换密码可见性",
|
||||
"signup_title": "创建一个 ntfy 帐户",
|
||||
"signup_form_username": "用户名",
|
||||
"signup_form_password": "密码",
|
||||
"signup_already_have_account": "已有帐户?登录!",
|
||||
"signup_disabled": "注册已禁用",
|
||||
"login_form_button_submit": "登录",
|
||||
"login_link_signup": "注册",
|
||||
"login_disabled": "登录已禁用",
|
||||
"action_bar_account": "帐户",
|
||||
"action_bar_reservation_edit": "更改保留",
|
||||
"subscribe_dialog_error_topic_already_reserved": "主题已保留",
|
||||
"account_basics_title": "帐户",
|
||||
"account_basics_username_title": "用户名",
|
||||
"account_basics_username_admin_tooltip": "你是管理员",
|
||||
"account_basics_password_title": "密码",
|
||||
"account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的帐户将很快被降级。",
|
||||
"account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费帐户。",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间",
|
||||
"account_upgrade_dialog_tier_selected_label": "已选",
|
||||
"account_upgrade_dialog_tier_current_label": "当前",
|
||||
"account_upgrade_dialog_button_cancel": "取消",
|
||||
"account_upgrade_dialog_button_redirect_signup": "立即注册",
|
||||
"account_tokens_title": "访问令牌",
|
||||
"account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的帐户凭据。查看<Link>文档</Link>以了解更多信息。",
|
||||
"account_tokens_table_token_header": "令牌",
|
||||
"account_tokens_table_label_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_copied_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_label": "标签,例如:Radarr 通知",
|
||||
"account_tokens_dialog_button_create": "创建令牌",
|
||||
"account_tokens_dialog_button_update": "更新令牌"
|
||||
}
|
||||
|
||||
@@ -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": "確認密碼"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/>;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user