Merge branch 'master' into almet/recaptcha

This commit is contained in:
Glandos 2021-10-10 22:19:58 +02:00 committed by GitHub
commit c7533ea6f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 326 additions and 162 deletions

View file

@ -9,6 +9,7 @@ This document describes changes between each past release.
Breaking changes Breaking changes
---------------- ----------------
- Enable session cookie security by default (#845)
- Drop support for Python 2 (#483) - Drop support for Python 2 (#483)
- Drop support for Python 3.5 (#571) - Drop support for Python 3.5 (#571)
- Drop support for MySQL (#743) - Drop support for MySQL (#743)
@ -25,6 +26,7 @@ Security
- Add CSRF validation on destructive actions (#796) - Add CSRF validation on destructive actions (#796)
- Ask for private code to delete project or project history (#796) - Ask for private code to delete project or project history (#796)
- Add headers to mitigate Clickjacking, XSS, and other attacks: `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`, `Content-Security-Policy`, `Referrer-Policy` (#845)
Added Added
----- -----
@ -37,7 +39,6 @@ Added
- Add sorting, pagination, and searching to the admin dashboard (#538) - Add sorting, pagination, and searching to the admin dashboard (#538)
- Add Project History page that records all changes (#553) - Add Project History page that records all changes (#553)
- Add token-based authentication to the API (#504) - Add token-based authentication to the API (#504)
- Add translations for Hindi, Portuguese (Brazil), Tamil
- Add illustrations as a showcase, currently only for French (#544) - Add illustrations as a showcase, currently only for French (#544)
- Add a page for downloading mobile application (#688) - Add a page for downloading mobile application (#688)
- Add translations for Greek, Esperanto, Italian, Japanese, Portuguese and Swedish - Add translations for Greek, Esperanto, Italian, Japanese, Portuguese and Swedish

View file

@ -21,6 +21,7 @@ ADMIN_PASSWORD = '$ADMIN_PASSWORD'
ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION
ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD
BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE" BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE"
SESSION_COOKIE_SECURE = $SESSION_COOKIE_SECURE
EOF EOF
# Start gunicorn without forking # Start gunicorn without forking

View file

@ -13,6 +13,21 @@ To know defaults on your deployed instance, simply look at your
"Production values" are the recommended values for use in production. "Production values" are the recommended values for use in production.
Configuration files
-------------------
By default, Ihatemoney loads its configuration from ``/etc/ihatemoney/ihatemoney.cfg``.
If you need to load the configuration from a custom path, you can define the
``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable with the path to the configuration
file.
For instance ::
export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg"
The path should be absolute. A relative path will be interpreted as being
inside ``/etc/ihatemoney/``.
`SQLALCHEMY_DATABASE_URI` `SQLALCHEMY_DATABASE_URI`
------------------------- -------------------------
@ -49,6 +64,21 @@ of the secret key could easily access any project and bypass the private code ve
- **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to - **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to
something random, which is good. something random, which is good.
`SESSION_COOKIE_SECURE`
-----------------------
A boolean that controls whether the session cookie will be marked "secure".
If this is the case, browsers will refuse to send the session cookie over plain HTTP.
- **Default value:** ``True``
- **Production value:** ``True`` if you run your service over HTTPS, ``False`` if you run
your service over plain HTTP.
Note: this setting is actually interpreted by Flask, see the
`Flask documentation`_ for details.
.. _Flask documentation: https://flask.palletsprojects.com/en/2.0.x/config/#SESSION_COOKIE_SECURE
`MAIL_DEFAULT_SENDER` `MAIL_DEFAULT_SENDER`
--------------------- ---------------------
@ -148,12 +178,3 @@ possible to configure it to act differently, thanks to the great
* **MAIL_PASSWORD** : default **None** * **MAIL_PASSWORD** : default **None**
* **DEFAULT_MAIL_SENDER** : default **None** * **DEFAULT_MAIL_SENDER** : default **None**
Using an alternate settings path
--------------------------------
You can put your settings file where you want, and pass its path to the
application using the ``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable.
For instance ::
export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg"

View file

@ -104,12 +104,20 @@ You can create a ``settings.cfg`` file, with the following content::
DEBUG = True DEBUG = True
SQLACHEMY_ECHO = DEBUG SQLACHEMY_ECHO = DEBUG
You can also set the `TESTING` flag to `True` so no mails are sent
(and no exception is raised) while you're on development mode.
Then before running the application, declare its path with :: Then before running the application, declare its path with ::
export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg" export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg"
You can also set the ``TESTING`` flag to ``True`` so no mails are sent
(and no exception is raised) while you're on development mode.
In some cases, you may need to disable secure cookies by setting
``SESSION_COOKIE_SECURE`` to ``False``. This is needed if you
access your dev server over the network: with the default value
of ``SESSION_COOKIE_SECURE``, the browser will refuse to send
the session cookie over insecure HTTP, so many features of Ihatemoney
won't work (project login, language change, etc).
.. _contributing-developer: .. _contributing-developer:
Contributing as a developer Contributing as a developer

View file

@ -65,6 +65,17 @@ If so, pick the ``pip`` commands to use in the relevant section(s) of
Then follow :ref:`general-procedure` from step 1. in order to complete the update. Then follow :ref:`general-procedure` from step 1. in order to complete the update.
Disable session cookie security if running over plain HTTP
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
.. note:: If you are running Ihatemoney over HTTPS, no special action is required.
Session cookies are now marked "secure" by default to increase security.
If you run Ihatemoney over plain HTTP, you need to explicitly disable this security
feature by setting ``SESSION_COOKIE_SECURE`` to ``False``, see :ref:`configuration`.
Switch to MariaDB >= 10.3.2 instead of MySQL Switch to MariaDB >= 10.3.2 instead of MySQL
++++++++++++++++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++++++++++++++++++++

View file

@ -35,7 +35,9 @@ def need_auth(f):
auth_token = auth_header.split(" ")[1] auth_token = auth_header.split(" ")[1]
except IndexError: except IndexError:
abort(401) abort(401)
project_id = Project.verify_token(auth_token, token_type="non_timed_token") project_id = Project.verify_token(
auth_token, token_type="auth", project_id=project_id
)
if auth_token and project_id: if auth_token and project_id:
project = Project.query.get(project_id) project = Project.query.get(project_id)
if project: if project:

View file

@ -44,3 +44,7 @@ ACTIVATE_ADMIN_DASHBOARD = False
# ENABLE_RECAPTCHA = True # ENABLE_RECAPTCHA = True
# RECAPTCHA_PUBLIC_KEY = "" # RECAPTCHA_PUBLIC_KEY = ""
# RECAPTCHA_PRIVATE_KEY = "" # RECAPTCHA_PRIVATE_KEY = ""
# Enable secure cookies. Requires HTTPS. Disable if you run your ihatemoney
# service over plain HTTP.
SESSION_COOKIE_SECURE = True

View file

@ -8,6 +8,7 @@ ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = "" ADMIN_PASSWORD = ""
ALLOW_PUBLIC_PROJECT_CREATION = True ALLOW_PUBLIC_PROJECT_CREATION = True
ACTIVATE_ADMIN_DASHBOARD = False ACTIVATE_ADMIN_DASHBOARD = False
SESSION_COOKIE_SECURE = True
SUPPORTED_LANGUAGES = [ SUPPORTED_LANGUAGES = [
"de", "de",
"el", "el",

View file

@ -13,6 +13,7 @@ from wtforms.fields.core import Label, SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField from wtforms.fields.html5 import DateField, DecimalField, URLField
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import ( from wtforms.validators import (
URL,
DataRequired, DataRequired,
Email, Email,
EqualTo, EqualTo,
@ -292,7 +293,7 @@ class BillForm(FlaskForm):
original_currency = SelectField(_("Currency"), validators=[DataRequired()]) original_currency = SelectField(_("Currency"), validators=[DataRequired()])
external_link = URLField( external_link = URLField(
_("External link"), _("External link"),
validators=[Optional()], validators=[Optional(), URL()],
description=_("A link to an external document, related to this bill"), description=_("A link to an external document, related to this bill"),
) )
payed_for = SelectMultipleField( payed_for = SelectMultipleField(

View file

@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
from itsdangerous import ( from itsdangerous import (
BadSignature, BadSignature,
SignatureExpired, SignatureExpired,
TimedJSONWebSignatureSerializer,
URLSafeSerializer, URLSafeSerializer,
URLSafeTimedSerializer,
) )
import sqlalchemy import sqlalchemy
from sqlalchemy import orm from sqlalchemy import orm
@ -339,41 +339,61 @@ class Project(db.Model):
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
def generate_token(self, expiration=0): def generate_token(self, token_type="auth"):
"""Generate a timed and serialized JsonWebToken """Generate a timed and serialized JsonWebToken
:param expiration: Token expiration time (in seconds) :param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
""" """
if expiration:
serializer = TimedJSONWebSignatureSerializer( if token_type == "reset":
current_app.config["SECRET_KEY"], expiration serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
) )
token = serializer.dumps({"project_id": self.id}).decode("utf-8") token = serializer.dumps([self.id])
else: else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"]) serializer = URLSafeSerializer(
token = serializer.dumps({"project_id": self.id}) current_app.config["SECRET_KEY"] + self.password, salt=token_type
)
token = serializer.dumps([self.id])
return token return token
@staticmethod @staticmethod
def verify_token(token, token_type="timed_token"): def verify_token(token, token_type="auth", project_id=None, max_age=3600):
"""Return the project id associated to the provided token, """Return the project id associated to the provided token,
None if the provided token is expired or not valid. None if the provided token is expired or not valid.
:param token: Serialized TimedJsonWebToken :param token: Serialized TimedJsonWebToken
:param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
:param project_id: Project ID. Used for token_type "auth" to use the password as serializer
secret key.
:param max_age: Token expiration time (in seconds). Only used with token_type "reset"
""" """
if token_type == "timed_token": loads_kwargs = {}
serializer = TimedJSONWebSignatureSerializer( if token_type == "reset":
current_app.config["SECRET_KEY"] serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
) )
loads_kwargs["max_age"] = max_age
else: else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"]) project = Project.query.get(project_id) if project_id is not None else None
password = project.password if project is not None else ""
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"] + password, salt=token_type
)
try: try:
data = serializer.loads(token) data = serializer.loads(token, **loads_kwargs)
except SignatureExpired: except SignatureExpired:
return None return None
except BadSignature: except BadSignature:
return None return None
return data["project_id"]
data_project = data[0] if isinstance(data, list) else None
return (
data_project if project_id is None or data_project == project_id else None
)
def __str__(self): def __str__(self):
return self.name return self.name

View file

@ -7,6 +7,7 @@ from flask import Flask, g, render_template, request, session
from flask_babel import Babel, format_currency from flask_babel import Babel, format_currency
from flask_mail import Mail from flask_mail import Mail
from flask_migrate import Migrate, stamp, upgrade from flask_migrate import Migrate, stamp, upgrade
from flask_talisman import Talisman
from jinja2 import pass_context from jinja2 import pass_context
from markupsafe import Markup from markupsafe import Markup
import pytz import pytz
@ -126,6 +127,24 @@ def create_app(
instance_relative_config=instance_relative_config, instance_relative_config=instance_relative_config,
) )
# If we need to load external JS/CSS/image resources, it needs to be added here, see
# https://github.com/wntrblm/flask-talisman#content-security-policy
csp = {
"default-src": ["'self'"],
# We have several inline javascript scripts :(
"script-src": ["'self'", "'unsafe-inline'"],
"object-src": "'none'",
}
Talisman(
app,
# Forcing HTTPS is the job of a reverse proxy
force_https=False,
# This is handled separately through the SESSION_COOKIE_SECURE Flask setting
session_cookie_secure=False,
content_security_policy=csp,
)
# If a configuration object is passed, use it. Otherwise try to find one. # If a configuration object is passed, use it. Otherwise try to find one.
load_configuration(app, configuration) load_configuration(app, configuration)
app.wsgi_app = PrefixedWSGI(app) app.wsgi_app = PrefixedWSGI(app)

View file

@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha
It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest. It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest.
You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. You can log in using this link: {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }} Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }}
If your cookie gets deleted or if you log out, you will need to log back in using the first link. If your cookie gets deleted or if you log out, you will need to log back in using the first link.

View file

@ -4,7 +4,7 @@ Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité
Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on soccupe du reste. Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on soccupe du reste.
Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }} Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }}
Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien. Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien.

View file

@ -1,7 +1,7 @@
Hi, Hi,
You requested to reset the password of the following project: "{{ project.name }}". You requested to reset the password of the following project: "{{ project.name }}".
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
This link is only valid for one hour. This link is only valid for one hour.
Hope this helps, Hope this helps,

View file

@ -1,7 +1,7 @@
Salut, Salut,
Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}". Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}".
Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
Ce lien est seulement valide pendant 1 heure. Ce lien est seulement valide pendant 1 heure.
Faites-en bon usage ! Faites-en bon usage !

View file

@ -21,8 +21,8 @@
</td> </td>
<td> <td>
{{ _("You can directly share the following link via your prefered medium") }}</br> {{ _("You can directly share the following link via your prefered medium") }}</br>
<a href="{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}"> <a href="{{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}">
{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }} {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
</a> </a>
</td> </td>
</tr> </tr>

View file

@ -213,7 +213,9 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette/token", headers=self.get_auth("raclette") "/api/projects/raclette/token", headers=self.get_auth("raclette")
) )
decoded_resp = json.loads(resp.data.decode("utf-8")) decoded_resp = json.loads(resp.data.decode("utf-8"))
resp = self.client.get("/authenticate?token={}".format(decoded_resp["token"])) resp = self.client.get(
f"/authenticate?token={decoded_resp['token']}&project_id=raclette"
)
# Test that we are redirected. # Test that we are redirected.
self.assertEqual(302, resp.status_code) self.assertEqual(302, resp.status_code)

View file

@ -4,6 +4,7 @@ import json
import re import re
from time import sleep from time import sleep
import unittest import unittest
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from flask import session from flask import session
import pytest import pytest
@ -11,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import models from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode from ihatemoney.versioning import LoggingMode
@ -87,11 +89,52 @@ class BudgetTestCase(IhatemoneyTestCase):
) )
# Test empty and invalid tokens # Test empty and invalid tokens
self.client.get("/exit") self.client.get("/exit")
# Use another project_id
parsed_url = urlparse(url)
query = parse_qs(parsed_url.query)
query["project_id"] = "invalid"
resp = self.client.get(
urlunparse(parsed_url._replace(query=urlencode(query, doseq=True)))
)
assert "You either provided a bad token" in resp.data.decode("utf-8")
resp = self.client.get("/authenticate") resp = self.client.get("/authenticate")
self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
resp = self.client.get("/authenticate?token=token") resp = self.client.get("/authenticate?token=token")
self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
def test_invite_code_invalidation(self):
"""Test that invitation link expire after code change"""
self.login("raclette")
self.post_project("raclette")
response = self.client.get("/raclette/invite").data.decode("utf-8")
link = extract_link(response, "share the following link")
self.client.get("/exit")
response = self.client.get(link)
# Link is valid
assert response.status_code == 302
# Change password to invalidate token
# Other data are required, but useless for the test
response = self.client.post(
"/raclette/edit",
data={
"name": "raclette",
"contact_email": "zorglub@notmyidea.org",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
assert response.status_code == 200
assert "alert-danger" not in response.data.decode("utf-8")
self.client.get("/exit")
response = self.client.get(link, follow_redirects=True)
# Link is invalid
self.assertIn("You either provided a bad token", response.data.decode("utf-8"))
def test_password_reminder(self): def test_password_reminder(self):
# test that it is possible to have an email containing the password of a # test that it is possible to have an email containing the password of a
# project in case people forget it (and it happens!) # project in case people forget it (and it happens!)
@ -632,6 +675,35 @@ class BudgetTestCase(IhatemoneyTestCase):
bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0] bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0]
self.assertEqual(bill.amount, 25.02) self.assertEqual(bill.amount, 25.02)
# add a bill with a valid external link
self.client.post(
"/raclette/add",
data={
"date": "2015-05-05",
"what": "fromage à raclette",
"payer": members_ids[0],
"payed_for": members_ids,
"amount": "42",
"external_link": "https://example.com/fromage",
},
)
bill = models.Bill.query.filter(models.Bill.date == "2015-05-05")[0]
self.assertEqual(bill.external_link, "https://example.com/fromage")
# add a bill with an invalid external link
resp = self.client.post(
"/raclette/add",
data={
"date": "2015-05-06",
"what": "mauvais fromage à raclette",
"payer": members_ids[0],
"payed_for": members_ids,
"amount": "42000",
"external_link": "javascript:alert('Tu bluffes, Martoni.')",
},
)
self.assertIn("Invalid URL", resp.data.decode("utf-8"))
def test_weighted_balance(self): def test_weighted_balance(self):
self.post_project("raclette") self.post_project("raclette")

View file

@ -1,5 +1,16 @@
from markupsafe import Markup
def em_surround(string, regex_escape=False): def em_surround(string, regex_escape=False):
if regex_escape: if regex_escape:
return r'<em class="font-italic">%s<\/em>' % string return r'<em class="font-italic">%s<\/em>' % string
else: else:
return '<em class="font-italic">%s</em>' % string return '<em class="font-italic">%s</em>' % string
def extract_link(data, start_prefix):
base_index = data.find(start_prefix)
start = data.find('href="', base_index) + 6
end = data.find('">', base_index)
link = Markup(data[start:end]).unescape()
return link

View file

@ -3,8 +3,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-08-19 20:34+0000\n" "PO-Revision-Date: 2021-09-23 19:36+0000\n"
"Last-Translator: corny <nico.eckstein+weblate@gmail.com>\n" "Last-Translator: Christian H. <sunrisechain@gmail.com>\n"
"Language-Team: German <https://hosted.weblate.org/projects/i-hate-money/" "Language-Team: German <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/de/>\n" "i-hate-money/de/>\n"
"Language: de\n" "Language: de\n"
@ -12,7 +12,7 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.8-dev\n" "X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -74,7 +74,7 @@ msgstr ""
"wähle eine andere Kennung" "wähle eine andere Kennung"
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr "Geben Sie Ihren privaten Code ein, um die Löschung zu bestätigen"
msgid "Unknown error" msgid "Unknown error"
msgstr "Unbekannter Fehler" msgstr "Unbekannter Fehler"

View file

@ -1,18 +1,18 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-07-10 15:34+0000\n" "PO-Revision-Date: 2021-10-01 20:35+0000\n"
"Last-Translator: phlostically <phlostically@mailinator.com>\n" "Last-Translator: phlostically <phlostically@mailinator.com>\n"
"Language-Team: Esperanto <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/eo/>\n"
"Language: eo\n" "Language: eo\n"
"Language-Team: Esperanto <https://hosted.weblate.org/projects/i-hate-"
"money/i-hate-money/eo/>\n"
"Plural-Forms: nplurals=2; plural=n != 1\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -76,13 +76,11 @@ msgstr ""
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr ""
#, fuzzy
msgid "Unknown error" msgid "Unknown error"
msgstr "Nekonata projekto" msgstr "Nekonata eraro"
#, fuzzy
msgid "Invalid private code." msgid "Invalid private code."
msgstr "Nova privata kodo" msgstr "Nevalida privata kodo."
msgid "Get in" msgid "Get in"
msgstr "Eniri" msgstr "Eniri"
@ -175,30 +173,30 @@ msgstr "La retpoŝta adreso %(email)s ne validas"
#. List with two items only #. List with two items only
msgid "{dual_object_0} and {dual_object_1}" msgid "{dual_object_0} and {dual_object_1}"
msgstr "" msgstr "{dual_object_0} kaj {dual_object_1}"
#. Last two items of a list with more than 3 items #. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}" msgid "{previous_object}, and {end_object}"
msgstr "" msgstr "{previous_object} kaj {end_object}"
#. Two items in a middle of a list with more than 5 objects #. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}" msgid "{previous_object}, {next_object}"
msgstr "" msgstr "{previous_object}, {next_object}"
#. First two items of a list with more than 3 items #. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}" msgid "{start_object}, {next_object}"
msgstr "" msgstr "{start_object}, {next_object}"
msgid "No Currency" msgid "No Currency"
msgstr "Neniu valuto" msgstr "Neniu valuto"
#. Form error with only one error #. Form error with only one error
msgid "{prefix}: {error}" msgid "{prefix}: {error}"
msgstr "" msgstr "{prefix}: {error}"
#. Form error with a list of errors #. Form error with a list of errors
msgid "{prefix}:<br />{errors}" msgid "{prefix}:<br />{errors}"
msgstr "" msgstr "{prefix}:<br />{errors}"
msgid "Too many failed login attempts, please retry later." msgid "Too many failed login attempts, please retry later."
msgstr "Tro da malsukcesaj provoj de salutado; bonvolu reprovi poste." msgstr "Tro da malsukcesaj provoj de salutado; bonvolu reprovi poste."
@ -396,16 +394,14 @@ msgstr "Elŝuti programon por poŝaparato"
msgid "Get it on" msgid "Get it on"
msgstr "Elŝuti ĝin ĉe" msgstr "Elŝuti ĝin ĉe"
#, fuzzy
msgid "Are you sure?" msgid "Are you sure?"
msgstr "ĉu vi certas?" msgstr "Ĉu vi certas?"
msgid "Edit project" msgid "Edit project"
msgstr "Redakti projekton" msgstr "Redakti projekton"
#, fuzzy
msgid "Delete project" msgid "Delete project"
msgstr "Redakti projekton" msgstr "Forviŝi projekton"
msgid "Import JSON" msgid "Import JSON"
msgstr "Enporti JSON-dosieron" msgstr "Enporti JSON-dosieron"
@ -453,7 +449,7 @@ msgid "Everyone"
msgstr "Ĉiuj" msgstr "Ĉiuj"
msgid "No one" msgid "No one"
msgstr "" msgstr "Neniu"
msgid "More options" msgid "More options"
msgstr "" msgstr ""

View file

@ -1,19 +1,19 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-05-10 11:33+0000\n" "PO-Revision-Date: 2021-09-20 12:38+0000\n"
"Last-Translator: Vsevolod <sevauserg.com@gmail.com>\n" "Last-Translator: Роман Прокопов <pochta.romana.iz.vyborga@gmail.com>\n"
"Language-Team: Russian <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/ru/>\n"
"Language: ru\n" "Language: ru\n"
"Language-Team: Russian <https://hosted.weblate.org/projects/i-hate-"
"money/i-hate-money/ru/>\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -26,9 +26,8 @@ msgstr ""
msgid "Project name" msgid "Project name"
msgstr "Имя проекта" msgstr "Имя проекта"
#, fuzzy
msgid "New private code" msgid "New private code"
msgstr "Приватный код" msgstr "Новый приватный код"
msgid "Enter a new code if you want to change it" msgid "Enter a new code if you want to change it"
msgstr "" msgstr ""

View file

@ -1,19 +1,18 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2020-10-12 04:47+0000\n" "PO-Revision-Date: 2021-10-10 05:05+0000\n"
"Last-Translator: Jwen921 <yangjingwen0921@gmail.com>\n" "Last-Translator: Frank.wu <me@wuzhiping.top>\n"
"Language-Team: Chinese (Simplified) <https://hosted.weblate.org/projects/"
"i-hate-money/i-hate-money/zh_Hans/>\n"
"Language: zh_Hans\n" "Language: zh_Hans\n"
"Language-Team: Chinese (Simplified) "
"<https://hosted.weblate.org/projects/i-hate-money/i-hate-money/zh_Hans/>"
"\n"
"Plural-Forms: nplurals=1; plural=0\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -24,12 +23,11 @@ msgstr "金额或符号无效。仅限数字与+-*/符号。"
msgid "Project name" msgid "Project name"
msgstr "账目名称" msgstr "账目名称"
#, fuzzy
msgid "New private code" msgid "New private code"
msgstr "共享密钥" msgstr "新的私人代码"
msgid "Enter a new code if you want to change it" msgid "Enter a new code if you want to change it"
msgstr "" msgstr "如要更改,请输入新代码"
msgid "Email" msgid "Email"
msgstr "邮箱" msgstr "邮箱"
@ -46,7 +44,7 @@ msgstr "默认货币"
msgid "" msgid ""
"This project cannot be set to 'no currency' because it contains bills in " "This project cannot be set to 'no currency' because it contains bills in "
"multiple currencies." "multiple currencies."
msgstr "" msgstr "此项目不能设置为“无货币”,因为它包含多种货币的账单。"
msgid "Import previously exported JSON file" msgid "Import previously exported JSON file"
msgstr "导入之前的JSON 文件" msgstr "导入之前的JSON 文件"
@ -70,15 +68,13 @@ msgid ""
msgstr "账目(“%(project)s”)已存在,请选择一个新名称" msgstr "账目(“%(project)s”)已存在,请选择一个新名称"
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr "请输入专用代码以确认删除"
#, fuzzy
msgid "Unknown error" msgid "Unknown error"
msgstr "未知项目" msgstr "未知错误"
#, fuzzy
msgid "Invalid private code." msgid "Invalid private code."
msgstr "共享密钥" msgstr "无效的私人代码。"
msgid "Get in" msgid "Get in"
msgstr "进入" msgstr "进入"
@ -171,40 +167,40 @@ msgstr "此邮箱%(email)s不存在"
#. List with two items only #. List with two items only
msgid "{dual_object_0} and {dual_object_1}" msgid "{dual_object_0} and {dual_object_1}"
msgstr "" msgstr "{dual_object_0} 和 {dual_object_1}"
#. Last two items of a list with more than 3 items #. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}" msgid "{previous_object}, and {end_object}"
msgstr "" msgstr "{previous_object} 和 {end_object}"
#. Two items in a middle of a list with more than 5 objects #. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}" msgid "{previous_object}, {next_object}"
msgstr "" msgstr "{previous_object}{next_object}"
#. First two items of a list with more than 3 items #. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}" msgid "{start_object}, {next_object}"
msgstr "" msgstr "{start_object}, {next_object}"
msgid "No Currency" msgid "No Currency"
msgstr "没有货币" msgstr "货币"
#. Form error with only one error #. Form error with only one error
msgid "{prefix}: {error}" msgid "{prefix}: {error}"
msgstr "" msgstr "{prefix}: {error}"
#. Form error with a list of errors #. Form error with a list of errors
msgid "{prefix}:<br />{errors}" msgid "{prefix}:<br />{errors}"
msgstr "" msgstr "{prefix}:<br />{errors}"
msgid "Too many failed login attempts, please retry later." msgid "Too many failed login attempts, please retry later."
msgstr "输入错误太多次了,请稍后重试。" msgstr "登录失败次数过多,请稍后重试。"
#, python-format #, python-format
msgid "This admin password is not the right one. Only %(num)d attempts left." msgid "This admin password is not the right one. Only %(num)d attempts left."
msgstr "管理密码有误,只剩 %(num)d次尝试机会" msgstr "管理密码有误,只剩 %(num)d次尝试机会"
msgid "You either provided a bad token or no project identifier." msgid "You either provided a bad token or no project identifier."
msgstr "你输入了错误的符号或没有项目标识符。" msgstr "你输入了错误的令牌或没有项目标识符。"
msgid "This private code is not the right one" msgid "This private code is not the right one"
msgstr "专用码不正确" msgstr "专用码不正确"
@ -241,7 +237,7 @@ msgid "Unknown project"
msgstr "未知项目" msgstr "未知项目"
msgid "Password successfully reset." msgid "Password successfully reset."
msgstr "密码重置成功" msgstr "密码重置成功"
msgid "Project successfully uploaded" msgid "Project successfully uploaded"
msgstr "项目成功上传" msgstr "项目成功上传"
@ -253,7 +249,7 @@ msgid "Project successfully deleted"
msgstr "项目成功删除" msgstr "项目成功删除"
msgid "Error deleting project" msgid "Error deleting project"
msgstr "" msgstr "删除项目时出错"
#, python-format #, python-format
msgid "You have been invited to share your expenses for %(project)s" msgid "You have been invited to share your expenses for %(project)s"
@ -273,20 +269,20 @@ msgid "%(member)s has been added"
msgstr "已添加%(member)s" msgstr "已添加%(member)s"
msgid "Error activating member" msgid "Error activating member"
msgstr "" msgstr "激活成员时出错"
#, python-format #, python-format
msgid "%(name)s is part of this project again" msgid "%(name)s is part of this project again"
msgstr "%(name)s 已经在项目里了" msgstr "%(name)s 已经在项目里了"
msgid "Error removing member" msgid "Error removing member"
msgstr "" msgstr "删除成员时出错"
#, python-format #, python-format
msgid "" msgid ""
"User '%(name)s' has been deactivated. It will still appear in the users " "User '%(name)s' has been deactivated. It will still appear in the users "
"list until its balance becomes zero." "list until its balance becomes zero."
msgstr "用户 '%(name)s'已被暂停在余额为0之前会继续显示在用户列表里" msgstr "用户 '%(name)s'已被暂停在余额为0之前会继续显示在用户列表里"
#, python-format #, python-format
msgid "User '%(name)s' has been removed" msgid "User '%(name)s' has been removed"
@ -300,7 +296,7 @@ msgid "The bill has been added"
msgstr "帐单已添加" msgstr "帐单已添加"
msgid "Error deleting bill" msgid "Error deleting bill"
msgstr "" msgstr "删除账单时出错"
msgid "The bill has been deleted" msgid "The bill has been deleted"
msgstr "账单已删除" msgstr "账单已删除"
@ -308,26 +304,23 @@ msgstr "账单已删除"
msgid "The bill has been modified" msgid "The bill has been modified"
msgstr "帐单已修改" msgstr "帐单已修改"
#, fuzzy
msgid "Error deleting project history" msgid "Error deleting project history"
msgstr "启用项目历史" msgstr "删除项目历史记录时出错"
#, fuzzy
msgid "Deleted project history." msgid "Deleted project history."
msgstr "启用项目历史" msgstr "已删除的项目历史记录。"
#, fuzzy
msgid "Error deleting recorded IP addresses" msgid "Error deleting recorded IP addresses"
msgstr "删除已储存的IP地址" msgstr "删除记录的IP地址时出错"
msgid "Deleted recorded IP addresses in project history." msgid "Deleted recorded IP addresses in project history."
msgstr "" msgstr "删除项目历史记录中的 IP 地址。"
msgid "Sorry, we were unable to find the page you've asked for." msgid "Sorry, we were unable to find the page you've asked for."
msgstr "对不起,未找到该页面" msgstr "抱歉,我们无法找到您要求的页面。"
msgid "The best thing to do is probably to get back to the main page." msgid "The best thing to do is probably to get back to the main page."
msgstr "最好的办法是返回主页" msgstr "最好的办法是返回主页"
msgid "Back to the list" msgid "Back to the list"
msgstr "返回列表" msgstr "返回列表"
@ -375,26 +368,22 @@ msgid "show"
msgstr "显示" msgstr "显示"
msgid "The Dashboard is currently deactivated." msgid "The Dashboard is currently deactivated."
msgstr "操作面板失效" msgstr "操作面板失效"
#, fuzzy
msgid "Download Mobile Application" msgid "Download Mobile Application"
msgstr "手机软件" msgstr "下载移动应用程序"
#, fuzzy
msgid "Get it on" msgid "Get it on"
msgstr "进入" msgstr "获取"
#, fuzzy
msgid "Are you sure?" msgid "Are you sure?"
msgstr "确定?" msgstr "是否确定?"
msgid "Edit project" msgid "Edit project"
msgstr "编辑项目" msgstr "编辑项目"
#, fuzzy
msgid "Delete project" msgid "Delete project"
msgstr "编辑项目" msgstr "删除项目"
msgid "Import JSON" msgid "Import JSON"
msgstr "导入json文件" msgstr "导入json文件"
@ -430,7 +419,7 @@ msgid "Edit the project"
msgstr "编辑项目" msgstr "编辑项目"
msgid "This will remove all bills and participants in this project!" msgid "This will remove all bills and participants in this project!"
msgstr "" msgstr "这将删除此项目的所有账单和参与者!"
msgid "Edit this bill" msgid "Edit this bill"
msgstr "编辑帐单" msgstr "编辑帐单"
@ -442,10 +431,10 @@ msgid "Everyone"
msgstr "每个人" msgstr "每个人"
msgid "No one" msgid "No one"
msgstr "" msgstr "无人"
msgid "More options" msgid "More options"
msgstr "" msgstr "更多选项"
msgid "Add participant" msgid "Add participant"
msgstr "添加参与人" msgstr "添加参与人"
@ -485,11 +474,11 @@ msgstr "历史设置改变"
#, python-format #, python-format
msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s" msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s"
msgstr "" msgstr "账单 %(name)s %(property_name)s 从 %(before)s 改为 %(after)s"
#, python-format #, python-format
msgid "Bill %(name)s: %(property_name)s changed to %(after)s" msgid "Bill %(name)s: %(property_name)s changed to %(after)s"
msgstr "" msgstr "账单 %(name)s %(property_name)s 改为 %(after)s"
msgid "Confirm Remove IP Adresses" msgid "Confirm Remove IP Adresses"
msgstr "确认移除IP地址" msgstr "确认移除IP地址"
@ -503,7 +492,6 @@ msgstr ""
"你确定要删除此项目里所有的IP地址吗\n" "你确定要删除此项目里所有的IP地址吗\n"
"项目其他内容不受影响,此操作不可撤回。" "项目其他内容不受影响,此操作不可撤回。"
#, fuzzy
msgid "Confirm deletion" msgid "Confirm deletion"
msgstr "确认删除" msgstr "确认删除"
@ -520,11 +508,11 @@ msgstr "确定删除此项目所有记录?此操作不可撤回。"
#, python-format #, python-format
msgid "Bill %(name)s: added %(owers_list_str)s to owers list" msgid "Bill %(name)s: added %(owers_list_str)s to owers list"
msgstr "" msgstr "帐单 %(name)s将 %(owers_list_str)s 添加到所有者列表"
#, python-format #, python-format
msgid "Bill %(name)s: removed %(owers_list_str)s from owers list" msgid "Bill %(name)s: removed %(owers_list_str)s from owers list"
msgstr "" msgstr "账单 %(name)s从所有者列表中删除了 %(owers_list_str)s"
#, python-format #, python-format
msgid "" msgid ""
@ -590,51 +578,51 @@ msgstr "IP地址记录可在设置里禁用"
msgid "From IP" msgid "From IP"
msgstr "从IP" msgstr "从IP"
#, fuzzy, python-format #, python-format
msgid "Project %(name)s added" msgid "Project %(name)s added"
msgstr "账目名称" msgstr "项目 %(name)s 已添加"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s added" msgid "Bill %(name)s added"
msgstr "帐单已添加" msgstr "帐单 %(name)s 已添加"
#, python-format #, python-format
msgid "Participant %(name)s added" msgid "Participant %(name)s added"
msgstr "" msgstr "成员 %(name)s 已添加"
msgid "Project private code changed" msgid "Project private code changed"
msgstr "项目专用码已更改" msgstr "项目专用码已更改"
#, fuzzy, python-format #, python-format
msgid "Project renamed to %(new_project_name)s" msgid "Project renamed to %(new_project_name)s"
msgstr "项目的标识符是%(project)s" msgstr "项目的标识符是 %(new_project_name)s"
#, fuzzy, python-format #, python-format
msgid "Project contact email changed to %(new_email)s" msgid "Project contact email changed to %(new_email)s"
msgstr "项目联系邮箱更改为" msgstr "项目联系邮箱更改为 %(new_email)s"
msgid "Project settings modified" msgid "Project settings modified"
msgstr "项目设置已修改" msgstr "项目设置已修改"
#, python-format #, python-format
msgid "Participant %(name)s deactivated" msgid "Participant %(name)s deactivated"
msgstr "" msgstr "成员 %(name)s 已停用"
#, python-format #, python-format
msgid "Participant %(name)s reactivated" msgid "Participant %(name)s reactivated"
msgstr "" msgstr "成员 %(name)s 被重新激活"
#, python-format #, python-format
msgid "Participant %(name)s renamed to %(new_name)s" msgid "Participant %(name)s renamed to %(new_name)s"
msgstr "" msgstr "成员 %(name)s 重命名为 %(new_name)s"
#, python-format #, python-format
msgid "Bill %(name)s renamed to %(new_description)s" msgid "Bill %(name)s renamed to %(new_description)s"
msgstr "" msgstr "账单 %(name)s 更名为 %(new_description)s"
#, python-format #, python-format
msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s" msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s"
msgstr "" msgstr "成员 %(name)s权重从%(old_weight)s变为%(new_weight)s"
msgid "Amount" msgid "Amount"
msgstr "数量" msgstr "数量"
@ -643,33 +631,33 @@ msgstr "数量"
msgid "Amount in %(currency)s" msgid "Amount in %(currency)s"
msgstr "%(currency)s的数量是" msgstr "%(currency)s的数量是"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s modified" msgid "Bill %(name)s modified"
msgstr "帐单已修改" msgstr "帐单 %(name)s 已修改"
#, python-format #, python-format
msgid "Participant %(name)s modified" msgid "Participant %(name)s modified"
msgstr "" msgstr "成员 %(name)s 已修改"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s removed" msgid "Bill %(name)s removed"
msgstr "用户 '%(name)s'已被移除" msgstr "账单 %(name)s 已被移除"
#, fuzzy, python-format #, python-format
msgid "Participant %(name)s removed" msgid "Participant %(name)s removed"
msgstr "用户 '%(name)s'已被移除" msgstr "用户 %(name)s 已被移除"
#, fuzzy, python-format #, python-format
msgid "Project %(name)s changed in an unknown way" msgid "Project %(name)s changed in an unknown way"
msgstr "未知的改变" msgstr "项目 %(name)s 以未知方式更改"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s changed in an unknown way" msgid "Bill %(name)s changed in an unknown way"
msgstr "未知的改变" msgstr "账单 %(name)s 以一种未知的方式更改"
#, fuzzy, python-format #, python-format
msgid "Participant %(name)s changed in an unknown way" msgid "Participant %(name)s changed in an unknown way"
msgstr "未知的改变" msgstr "成员 %(name)s 以未知方式更改"
msgid "Nothing to list" msgid "Nothing to list"
msgstr "无列表" msgstr "无列表"
@ -815,7 +803,7 @@ msgid "No bills"
msgstr "没有账单" msgstr "没有账单"
msgid "Nothing to list yet." msgid "Nothing to list yet."
msgstr "没有列表" msgstr "没有列表"
msgid "You probably want to" msgid "You probably want to"
msgstr "你想要" msgstr "你想要"

View file

@ -200,15 +200,21 @@ def admin():
def authenticate(project_id=None): def authenticate(project_id=None):
"""Authentication form""" """Authentication form"""
form = AuthenticationForm() form = AuthenticationForm()
if not form.id.data and request.args.get("project_id"):
form.id.data = request.args["project_id"]
project_id = form.id.data
# Try to get project_id from token first # Try to get project_id from token first
token = request.args.get("token") token = request.args.get("token")
if token: if token:
project_id = Project.verify_token(token, token_type="non_timed_token") verified_project_id = Project.verify_token(
token_auth = True token, token_type="auth", project_id=project_id
)
if verified_project_id == project_id:
token_auth = True
else:
project_id = None
else: else:
if not form.id.data and request.args.get("project_id"):
form.id.data = request.args["project_id"]
project_id = form.id.data
token_auth = False token_auth = False
if project_id is None: if project_id is None:
# User doesn't provide project identifier or a valid token # User doesn't provide project identifier or a valid token
@ -389,7 +395,7 @@ def reset_password():
return render_template( return render_template(
"reset_password.html", form=form, error=_("No token provided") "reset_password.html", form=form, error=_("No token provided")
) )
project_id = Project.verify_token(token) project_id = Project.verify_token(token, token_type="reset")
if not project_id: if not project_id:
return render_template( return render_template(
"reset_password.html", form=form, error=_("Invalid token") "reset_password.html", form=form, error=_("Invalid token")

View file

@ -33,6 +33,7 @@ install_requires =
Flask-Migrate>=2.5.3,<4 # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b) Flask-Migrate>=2.5.3,<4 # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b)
Flask-RESTful>=0.3.9,<1 Flask-RESTful>=0.3.9,<1
Flask-SQLAlchemy>=2.4,<3 Flask-SQLAlchemy>=2.4,<3
Flask-Talisman>=0.8,<1
Flask-WTF>=0.14.3,<1 Flask-WTF>=0.14.3,<1
WTForms>=2.3.1,<2.4 WTForms>=2.3.1,<2.4
Flask>=2,<3 Flask>=2,<3