mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-06 05:01:48 +02:00
Merge branch 'master' into almet/recaptcha
This commit is contained in:
commit
c7533ea6f2
29 changed files with 326 additions and 162 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
++++++++++++++++++++++++++++++++++++++++++++
|
++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 s’occupe du reste.
|
Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on s’occupe 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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 !
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Binary file not shown.
|
@ -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"
|
||||||
|
|
Binary file not shown.
|
@ -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 ""
|
||||||
|
|
Binary file not shown.
|
@ -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 ""
|
||||||
|
|
Binary file not shown.
|
@ -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 "你想要"
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue