Compare commits
	
		
			17 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| abe90d3483 | |||
| 65e7dcdf6d | |||
| 74e414badf | |||
| 69175979ff | |||
| 2f69e0f215 | |||
| 961385c389 | |||
| a691cfd2da | |||
| 482a0faa23 | |||
| 0ecf7b6617 | |||
| 4408bbfc82 | |||
| 433110f486 | |||
| 0b1dd4f4fc | |||
| 46bd27e126 | |||
| b718d19450 | |||
| 2969e83afe | |||
| a732656746 | |||
| 1daed940b6 | 
| @@ -2,6 +2,39 @@ Change Log | |||||||
| ========== | ========== | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.5.4 | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | Released 2023/5/18 | ||||||
|  |  | ||||||
|  | Security fixes. | ||||||
|  |  | ||||||
|  | * Added safeguard to the next URI utilities, to prevent Cross-Site | ||||||
|  |   Scripting (XSS) attacks. | ||||||
|  | * Applied the safe next URI utilities to the test site. | ||||||
|  | * Added the ``SameSite`` and ``Secure`` flags to the session cookie | ||||||
|  |   of the test site. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.5.3 | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | Released 2023/4/30 | ||||||
|  |  | ||||||
|  | * Fixed the error of the net balance in the unmatched offset list. | ||||||
|  | * Revised the original line item editor not to override the existing | ||||||
|  |   amount when the existing amount is less or equal to the net | ||||||
|  |   balance. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.5.2 | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | Released 2023/4/30 | ||||||
|  |  | ||||||
|  | * Fixed the error of the net balance in the unmatched offset list. | ||||||
|  |  | ||||||
|  |  | ||||||
| Version 1.5.1 | Version 1.5.1 | ||||||
| ------------- | ------------- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -50,9 +50,9 @@ The following front-end JavaScript libraries must be loaded.  You may | |||||||
| download it locally or use CDN_. | download it locally or use CDN_. | ||||||
|  |  | ||||||
| * Bootstrap_ 5.2.3 or above | * Bootstrap_ 5.2.3 or above | ||||||
| * FontAwesome_ 6.2.1 or above | * FontAwesome_ 6.4.0 or above | ||||||
| * `Decimal.js`_ 6.4.3 or above | * `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above. | ||||||
| * `Tempus-Dominus`_ 6.4.3 or above | * `Tempus-Dominus`_ 6.7.7 or above | ||||||
|  |  | ||||||
|  |  | ||||||
| Configuration | Configuration | ||||||
| @@ -114,6 +114,7 @@ Check your Flask application and see how it works. | |||||||
| .. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network | .. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network | ||||||
| .. _Bootstrap: https://getbootstrap.com | .. _Bootstrap: https://getbootstrap.com | ||||||
| .. _FontAwesome: https://fontawesome.com | .. _FontAwesome: https://fontawesome.com | ||||||
| .. _Decimal.js: https://mikemcl.github.io/decimal.js | .. _decimal.js: https://mikemcl.github.io/decimal.js | ||||||
|  | .. _decimal.js-light: https://mikemcl.github.io/decimal.js-light | ||||||
| .. _Tempus-Dominus: https://getdatepicker.com | .. _Tempus-Dominus: https://getdatepicker.com | ||||||
| .. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/ | .. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/ | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ from flask_sqlalchemy import SQLAlchemy | |||||||
|  |  | ||||||
| from accounting.utils.user import UserUtilityInterface | from accounting.utils.user import UserUtilityInterface | ||||||
|  |  | ||||||
| VERSION: str = "1.5.1" | VERSION: str = "1.5.4" | ||||||
| """The package version.""" | """The package version.""" | ||||||
| db: SQLAlchemy = SQLAlchemy() | db: SQLAlchemy = SQLAlchemy() | ||||||
| """The database instance.""" | """The database instance.""" | ||||||
|   | |||||||
| @@ -182,6 +182,8 @@ class Account(db.Model): | |||||||
|         :param value: The new title. |         :param value: The new title. | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|  |         if self.title == value: | ||||||
|  |             return | ||||||
|         if self.title_l10n is None: |         if self.title_l10n is None: | ||||||
|             self.title_l10n = value |             self.title_l10n = value | ||||||
|             return |             return | ||||||
| @@ -424,6 +426,8 @@ class Currency(db.Model): | |||||||
|         :param value: The new name. |         :param value: The new name. | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|  |         if self.name == value: | ||||||
|  |             return | ||||||
|         if self.name_l10n is None: |         if self.name_l10n is None: | ||||||
|             self.name_l10n = value |             self.name_l10n = value | ||||||
|             return |             return | ||||||
|   | |||||||
| @@ -123,15 +123,13 @@ class OffsetMatcher: | |||||||
|             .options(selectinload(JournalEntryLineItem.currency), |             .options(selectinload(JournalEntryLineItem.currency), | ||||||
|                      selectinload(JournalEntryLineItem.journal_entry)).all() |                      selectinload(JournalEntryLineItem.journal_entry)).all() | ||||||
|         for line_item in self.line_items: |         for line_item in self.line_items: | ||||||
|             line_item.is_offset = line_item.id in net_balances |             line_item.is_offset = line_item.id not in net_balances | ||||||
|         self.unapplied = [x for x in self.line_items |         self.unapplied = [x for x in self.line_items if not x.is_offset] | ||||||
|                           if x.is_offset] |  | ||||||
|         for line_item in self.unapplied: |         for line_item in self.unapplied: | ||||||
|             line_item.net_balance = line_item.amount \ |             line_item.net_balance = line_item.amount \ | ||||||
|                 if net_balances[line_item.id] is None \ |                 if net_balances[line_item.id] is None \ | ||||||
|                 else net_balances[line_item.id] |                 else net_balances[line_item.id] | ||||||
|         self.unmatched = [x for x in self.line_items |         self.unmatched = [x for x in self.line_items if x.is_offset] | ||||||
|                           if not x.is_offset] |  | ||||||
|         self.__populate_accumulated_balances() |         self.__populate_accumulated_balances() | ||||||
|  |  | ||||||
|     def __populate_accumulated_balances(self) -> None: |     def __populate_accumulated_balances(self) -> None: | ||||||
|   | |||||||
| @@ -290,7 +290,9 @@ class JournalEntryLineItemEditor { | |||||||
|         this.account = originalLineItem.account.copy(); |         this.account = originalLineItem.account.copy(); | ||||||
|         this.isAccountConfirmed = false; |         this.isAccountConfirmed = false; | ||||||
|         this.#accountText.innerText = this.account.text; |         this.#accountText.innerText = this.account.text; | ||||||
|         this.#amountInput.value = String(originalLineItem.netBalance); |         if (this.#amountInput.value === "" || new Decimal(this.#amountInput.value).greaterThan(originalLineItem.netBalance)) { | ||||||
|  |             this.#amountInput.value = String(originalLineItem.netBalance); | ||||||
|  |         } | ||||||
|         this.#amountInput.max = String(originalLineItem.netBalance); |         this.#amountInput.max = String(originalLineItem.netBalance); | ||||||
|         this.#amountInput.min = "0"; |         this.#amountInput.min = "0"; | ||||||
|         this.#validate(); |         this.#validate(); | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ First written: 2023/1/30 | |||||||
|       {{ A_("New") }} |       {{ A_("New") }} | ||||||
|     </a> |     </a> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> |   <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> | ||||||
|     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> |     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> | ||||||
|     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> |     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|   | |||||||
| @@ -26,7 +26,7 @@ First written: 2023/1/26 | |||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="mb-2 accounting-toolbar"> | <div class="mb-2 accounting-toolbar"> | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> |   <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> | ||||||
|     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> |     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> | ||||||
|     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> |     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ First written: 2023/2/6 | |||||||
|       {{ A_("New") }} |       {{ A_("New") }} | ||||||
|     </a> |     </a> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> |   <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> | ||||||
|     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> |     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> | ||||||
|     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> |     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ description-editor-modal.html: The modal of the description editor | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/2/28 | First written: 2023/2/28 | ||||||
| #} | #} | ||||||
| <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}"> | <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" name="accounting-dummy-form" data-debit-credit="{{ description_editor.debit_credit }}"> | ||||||
|   <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> |   <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> | ||||||
|     <div class="modal-dialog"> |     <div class="modal-dialog"> | ||||||
|       <div class="modal-content"> |       <div class="modal-content"> | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ journal-entry-line-item-editor-modal: The modal of the journal entry line item e | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/2/25 | First written: 2023/2/25 | ||||||
| #} | #} | ||||||
| <form id="accounting-line-item-editor"> | <form id="accounting-line-item-editor" name="accounting-dummy-form"> | ||||||
|   <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> |   <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> | ||||||
|     <div class="modal-dialog"> |     <div class="modal-dialog"> | ||||||
|       <div class="modal-content"> |       <div class="modal-content"> | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ recurring-item-editor-modal.html: The modal of the recurring item editor | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/3/22 | First written: 2023/3/22 | ||||||
| #} | #} | ||||||
| <form id="accounting-recurring-item-editor-{{ expense_income }}"> | <form id="accounting-recurring-item-editor-{{ expense_income }}" name="accounting-dummy-form"> | ||||||
|   <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true"> |   <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true"> | ||||||
|     <div class="modal-dialog"> |     <div class="modal-dialog"> | ||||||
|       <div class="modal-content"> |       <div class="modal-content"> | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ search-modal.html: The search modal | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/3/8 | First written: 2023/3/8 | ||||||
| #} | #} | ||||||
| <form action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label"> | <form action="{{ url_for("accounting-report.search") }}" name="accounting-search-form" method="get" role="search" aria-labelledby="accounting-search-modal-label"> | ||||||
|   <div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true"> |   <div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true"> | ||||||
|     <div class="modal-dialog"> |     <div class="modal-dialog"> | ||||||
|       <div class="modal-content"> |       <div class="modal-content"> | ||||||
|   | |||||||
| @@ -118,7 +118,7 @@ First written: 2023/3/8 | |||||||
|   </button> |   </button> | ||||||
| {% endif %} | {% endif %} | ||||||
| {% if use_search %} | {% if use_search %} | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> |   <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> | ||||||
|     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> |     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> | ||||||
|     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> |     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|   | |||||||
| @@ -14,8 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The utility to cast a SQLAlchemy column into the column type, to avoid | """The utilities to cast values into desired types, to avoid IDE warnings. | ||||||
| warnings from the IDE. |  | ||||||
|  |  | ||||||
| This module should not import any other module from the application. | This module should not import any other module from the application. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,7 +21,6 @@ from typing import Self | |||||||
|  |  | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
|  |  | ||||||
| from accounting import db |  | ||||||
| from accounting.locale import gettext | from accounting.locale import gettext | ||||||
| from accounting.models import Account | from accounting.models import Account | ||||||
|  |  | ||||||
| @@ -75,7 +74,7 @@ class CurrentAccount: | |||||||
|         """ |         """ | ||||||
|         accounts: list[cls] = [cls.current_assets_and_liabilities()] |         accounts: list[cls] = [cls.current_assets_and_liabilities()] | ||||||
|         accounts.extend([CurrentAccount(x) |         accounts.extend([CurrentAccount(x) | ||||||
|                          for x in db.session.query(Account) |                          for x in Account.query | ||||||
|                         .filter(cls.sql_condition()) |                         .filter(cls.sql_condition()) | ||||||
|                         .order_by(Account.base_code, Account.no)]) |                         .order_by(Account.base_code, Account.no)]) | ||||||
|         return accounts |         return accounts | ||||||
|   | |||||||
| @@ -41,11 +41,8 @@ def inherit_next(uri: str) -> str: | |||||||
|     :param uri: The URI. |     :param uri: The URI. | ||||||
|     :return: The URI with the current next URI added at the query argument. |     :return: The URI with the current next URI added at the query argument. | ||||||
|     """ |     """ | ||||||
|     next_uri: str | None = request.form.get("next") \ |     next_uri: str | None = __get_next_uri() | ||||||
|         if request.method == "POST" else request.args.get("next") |     return uri if next_uri is None else __set_next(uri, next_uri) | ||||||
|     if next_uri is None: |  | ||||||
|         return uri |  | ||||||
|     return __set_next(uri, next_uri) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def or_next(uri: str) -> str: | def or_next(uri: str) -> str: | ||||||
| @@ -54,9 +51,22 @@ def or_next(uri: str) -> str: | |||||||
|     :param uri: The URI. |     :param uri: The URI. | ||||||
|     :return: The next URI or the supplied URI. |     :return: The next URI or the supplied URI. | ||||||
|     """ |     """ | ||||||
|  |     next_uri: str | None = __get_next_uri() | ||||||
|  |     return uri if next_uri is None else next_uri | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_next_uri() -> str | None: | ||||||
|  |     """Returns the valid next URI. | ||||||
|  |  | ||||||
|  |     :return: The valid next URI. | ||||||
|  |     """ | ||||||
|     next_uri: str | None = request.form.get("next") \ |     next_uri: str | None = request.form.get("next") \ | ||||||
|         if request.method == "POST" else request.args.get("next") |         if request.method == "POST" else request.args.get("next") | ||||||
|     return uri if next_uri is None else next_uri |     if next_uri is None or not next_uri.startswith("/"): | ||||||
|  |         return None | ||||||
|  |     if len(next_uri) > 512: | ||||||
|  |         return next_uri[:512] | ||||||
|  |     return next_uri | ||||||
|  |  | ||||||
|  |  | ||||||
| def __set_next(uri: str, next_uri: str) -> str: | def __set_next(uri: str, next_uri: str) -> str: | ||||||
|   | |||||||
| @@ -2153,7 +2153,7 @@ class JournalEntryReorderTestCase(unittest.TestCase): | |||||||
|             self.assertEqual(db.session.get(JournalEntry, id_1).no, 1) |             self.assertEqual(db.session.get(JournalEntry, id_1).no, 1) | ||||||
|             self.assertEqual(db.session.get(JournalEntry, id_2).no, 3) |             self.assertEqual(db.session.get(JournalEntry, id_2).no, 3) | ||||||
|             self.assertEqual(db.session.get(JournalEntry, id_3).no, 2) |             self.assertEqual(db.session.get(JournalEntry, id_3).no, 2) | ||||||
|             self.assertEqual(db.session.get(JournalEntry, id_4).no, 1) |             self.assertEqual(   db.session.get(JournalEntry, id_4).no, 1) | ||||||
|             self.assertEqual(db.session.get(JournalEntry, id_5).no, 2) |             self.assertEqual(db.session.get(JournalEntry, id_5).no, 2) | ||||||
|  |  | ||||||
|     def test_reorder(self) -> None: |     def test_reorder(self) -> None: | ||||||
| @@ -2181,14 +2181,14 @@ class JournalEntryReorderTestCase(unittest.TestCase): | |||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             f"{PREFIX}/dates/{date.isoformat()}", |             f"{PREFIX}/dates/{date.isoformat()}", | ||||||
|             data={"csrf_token": self.csrf_token, |             data={"csrf_token": self.csrf_token, | ||||||
|                   "next": "/next", |                   "next": NEXT_URI, | ||||||
|                   f"{id_1}-no": "4", |                   f"{id_1}-no": "4", | ||||||
|                   f"{id_2}-no": "1", |                   f"{id_2}-no": "1", | ||||||
|                   f"{id_3}-no": "5", |                   f"{id_3}-no": "5", | ||||||
|                   f"{id_4}-no": "2", |                   f"{id_4}-no": "2", | ||||||
|                   f"{id_5}-no": "3"}) |                   f"{id_5}-no": "3"}) | ||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], f"/next") |         self.assertEqual(response.headers["Location"], NEXT_URI) | ||||||
|  |  | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|             self.assertEqual(db.session.get(JournalEntry, id_1).no, 4) |             self.assertEqual(db.session.get(JournalEntry, id_1).no, 4) | ||||||
| @@ -2209,12 +2209,12 @@ class JournalEntryReorderTestCase(unittest.TestCase): | |||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             f"{PREFIX}/dates/{date.isoformat()}", |             f"{PREFIX}/dates/{date.isoformat()}", | ||||||
|             data={"csrf_token": self.csrf_token, |             data={"csrf_token": self.csrf_token, | ||||||
|                   "next": "/next", |                   "next": NEXT_URI, | ||||||
|                   f"{id_2}-no": "3a", |                   f"{id_2}-no": "3a", | ||||||
|                   f"{id_3}-no": "5", |                   f"{id_3}-no": "5", | ||||||
|                   f"{id_4}-no": "2"}) |                   f"{id_4}-no": "2"}) | ||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.headers["Location"], f"/next") |         self.assertEqual(response.headers["Location"], NEXT_URI) | ||||||
|  |  | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|             self.assertEqual(db.session.get(JournalEntry, id_1).no, 3) |             self.assertEqual(db.session.get(JournalEntry, id_1).no, 3) | ||||||
|   | |||||||
| @@ -52,6 +52,8 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|     db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite" |     db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite" | ||||||
|     app.config.from_mapping({ |     app.config.from_mapping({ | ||||||
|         "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)), |         "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)), | ||||||
|  |         "SESSION_COOKIE_SAMESITE": "Lax", | ||||||
|  |         "SESSION_COOKIE_SECURE": True, | ||||||
|         "SQLALCHEMY_DATABASE_URI": db_uri, |         "SQLALCHEMY_DATABASE_URI": db_uri, | ||||||
|         "BABEL_DEFAULT_LOCALE": "en", |         "BABEL_DEFAULT_LOCALE": "en", | ||||||
|         "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", |         "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", | ||||||
|   | |||||||
| @@ -19,10 +19,12 @@ | |||||||
| """ | """ | ||||||
| from babel import Locale | from babel import Locale | ||||||
| from flask import request, session, current_app, Blueprint, Response, \ | from flask import request, session, current_app, Blueprint, Response, \ | ||||||
|     redirect, url_for, Flask |     redirect, Flask | ||||||
| from flask_babel import Babel | from flask_babel import Babel | ||||||
| from werkzeug.datastructures import LanguageAccept | from werkzeug.datastructures import LanguageAccept | ||||||
|  |  | ||||||
|  | from accounting.utils.next_uri import or_next | ||||||
|  |  | ||||||
| bp: Blueprint = Blueprint("locale", __name__, url_prefix="/") | bp: Blueprint = Blueprint("locale", __name__, url_prefix="/") | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -68,9 +70,7 @@ def set_locale() -> Response: | |||||||
|     all_linguas: dict[str, str] = get_all_linguas() |     all_linguas: dict[str, str] = get_all_linguas() | ||||||
|     if "locale" in request.form and request.form["locale"] in all_linguas: |     if "locale" in request.form and request.form["locale"] in all_linguas: | ||||||
|         session["locale"] = request.form["locale"] |         session["locale"] = request.form["locale"] | ||||||
|     if "next" in request.form: |     return redirect(or_next("/")) | ||||||
|         return redirect(request.form["next"]) |  | ||||||
|     return redirect(url_for("home.home")) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_all_linguas() -> dict[str, str]: | def get_all_linguas() -> dict[str, str]: | ||||||
|   | |||||||
| @@ -26,13 +26,13 @@ First written: 2023/1/27 | |||||||
|   <meta name="viewport" content="width=device-width, initial-scale=1"> |   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||||
|   <meta name="author" content="{{ "imacat" }}" /> |   <meta name="author" content="{{ "imacat" }}" /> | ||||||
|   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous"> |   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous"> | ||||||
|   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css" crossorigin="anonymous"> |   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css" crossorigin="anonymous"> | ||||||
|   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" crossorigin="anonymous"> |   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/css/tempus-dominus.min.css" crossorigin="anonymous"> | ||||||
|   {% block styles %}{% endblock %} |   {% block styles %}{% endblock %} | ||||||
|   <script src="{{ url_for("babel_catalog") }}"></script> |   <script src="{{ url_for("babel_catalog") }}"></script> | ||||||
|   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script> |   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script> | ||||||
|   <script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script> |   <script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script> | ||||||
|   <script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/js/tempus-dominus.min.js" integrity="sha384-2MkID2vkc9sxBCqs2us3mB8fV+c0o7uPtOvAPjaC8gKv9Bk21UHT0r2Q7Kv70+zO" crossorigin="anonymous"></script> |   <script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/js/tempus-dominus.min.js" integrity="sha384-MxHp+/TqTjbku1jSTIe1e/4l6CZTLhACLDbWyxYaFRgD3AM4oh99AY8bxsGhIoRc" crossorigin="anonymous"></script> | ||||||
|   {% block scripts %}{% endblock %} |   {% block scripts %}{% endblock %} | ||||||
|   <link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}"> |   <link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}"> | ||||||
|   <title>{% block title %}{% endblock %}</title> |   <title>{% block title %}{% endblock %}</title> | ||||||
|   | |||||||
| @@ -101,6 +101,60 @@ class NextUriTestCase(unittest.TestCase): | |||||||
|                                                       "name": "viewer"}) |                                                       "name": "viewer"}) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_invalid(self) -> None: | ||||||
|  |         """Tests the next URI utilities without an invalid next URI. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         def test_invalid_next_uri_view() -> str: | ||||||
|  |             """The test view without the next URI.""" | ||||||
|  |             self.assertEqual(inherit_next(self.TARGET), | ||||||
|  |                              request.args.get("inherit-expected")) | ||||||
|  |             self.assertEqual(or_next(self.TARGET), | ||||||
|  |                              request.args.get("or-expected")) | ||||||
|  |             return "" | ||||||
|  |  | ||||||
|  |         self.app.add_url_rule("/test-invalid-next", | ||||||
|  |                               view_func=test_invalid_next_uri_view, | ||||||
|  |                               methods=["GET", "POST"]) | ||||||
|  |         client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) | ||||||
|  |         client.headers["Referer"] = TEST_SERVER | ||||||
|  |         csrf_token: str = get_csrf_token(client) | ||||||
|  |         next_uri: str | ||||||
|  |         expected1: str | ||||||
|  |         expected2: str | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         # A foreign URI | ||||||
|  |         next_uri = "https://example.com" | ||||||
|  |         expected1 = self.TARGET | ||||||
|  |         expected2 = self.TARGET | ||||||
|  |         response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}" | ||||||
|  |                               f"&inherit-expected={quote_plus(expected1)}" | ||||||
|  |                               f"&or-expected={quote_plus(expected2)}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         response = client.post("/test-invalid-next" | ||||||
|  |                                f"?inherit-expected={quote_plus(expected1)}" | ||||||
|  |                                f"&or-expected={quote_plus(expected2)}", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "next": next_uri}) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         # An extremely-long URI to trigger the error | ||||||
|  |         next_uri = "/" + "x" * 1024 | ||||||
|  |         expected2 = next_uri[:512] | ||||||
|  |         expected1 = f"{self.TARGET}?next={quote_plus(expected2)}" | ||||||
|  |         response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}" | ||||||
|  |                               f"&inherit-expected={quote_plus(expected1)}" | ||||||
|  |                               f"&or-expected={quote_plus(expected2)}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         response = client.post("/test-invalid-next" | ||||||
|  |                                f"?inherit-expected={quote_plus(expected1)}" | ||||||
|  |                                f"&or-expected={quote_plus(expected2)}", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "next": next_uri}) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |  | ||||||
| class QueryKeywordParserTestCase(unittest.TestCase): | class QueryKeywordParserTestCase(unittest.TestCase): | ||||||
|     """The test case for the query keyword parser.""" |     """The test case for the query keyword parser.""" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user