1
0

feat: support login

- migrate old API into typescript but not finished (only webLogin works now)
- seperate the logger of backend due to the shitty behavior of Flask (change logging level)
This commit is contained in:
2026-05-14 21:13:13 +08:00
parent 6337ae432d
commit 2a280dcba0
15 changed files with 1013 additions and 157 deletions

View File

@@ -1,12 +1,14 @@
import sys
import logging
from argparse import ArgumentParser
from typing import cast
from pathlib import Path
import server
import config
import utils
import database
import logger
from logger import LOGGER, LoggerLevel
def GetUsernamePassword() -> tuple[str, str]:
@@ -26,15 +28,10 @@ def GetUsernamePassword() -> tuple[str, str]:
return (username, password)
def SetLoggingStyle(level: int) -> None:
logging.basicConfig(format="[%(levelname)s] %(message)s", level=level)
if __name__ == "__main__":
# Set as INFO level in default first,
# and we will change it once we load the configuration file.
SetLoggingStyle(logging.INFO)
logger.set_level(LoggerLevel.INFO)
# Receive arguments
parser = ArgumentParser(
@@ -60,21 +57,21 @@ if __name__ == "__main__":
args = parser.parse_args()
# Show splash
logging.info("Coconut-leaf")
logging.info("A light, self-host and multi-account calendar system")
logging.info("Project: https://github.com/yyc12345/coconut-leaf")
logging.info("===================")
LOGGER.info("Coconut-leaf")
LOGGER.info("A light, self-host and multi-account calendar system")
LOGGER.info("Project: https://github.com/yyc12345/coconut-leaf")
LOGGER.info("===================")
# Load config file
try:
config.setup_config(cast(Path, args.config))
except Exception as e:
logging.critical(f"Error loading config file: {e}")
LOGGER.critical(f"Error loading config file: {e}")
sys.exit(1)
# Change logging level again according to whether enable debug mode
logging_level = logging.DEBUG if config.get_config().others.debug else logging.INFO
SetLoggingStyle(logging_level)
logging_level = LoggerLevel.DEBUG if config.get_config().others.debug else LoggerLevel.INFO
logger.set_level(logging_level)
# Initialize the calendar system if needed
if cast(bool, args.init):
@@ -83,5 +80,5 @@ if __name__ == "__main__":
calendar.init(*gotten_data)
calendar.close()
logging.info("Staring server...")
LOGGER.info("Staring server...")
server.run()

View File

@@ -1,11 +1,23 @@
import config
import sqlite3
import utils
import threading
import logging
import dt
from typing import cast
from pathlib import Path
from dataclasses import dataclass
from typing import Any
import dt
import utils
import config
from logger import LOGGER
@dataclass(frozen=True)
class ResponseBody:
success: bool
"""True if this operation is successful, otherwise false."""
error: str
"""The error message provided when operation failed."""
data: Any
"""The payload provided when operation successed."""
def SafeDatabaseOperation(func):
def wrapper(self: 'CalendarDatabase', *args, **kwargs):
@@ -19,18 +31,18 @@ def SafeDatabaseOperation(func):
except Exception as e:
self.cursor = None
if cfg.others.debug:
logging.exception(e)
return (False, str(e), None)
LOGGER.exception(e)
return ResponseBody(False, str(e), None)
# do real data work
try:
currentTime = utils.GetCurrentTimestamp()
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
self.latestClean = currentTime
logging.info('Cleaning outdated token...')
LOGGER.info('Cleaning outdated token...')
self.tokenOper_clean()
result = (True, '', func(self, *args, **kwargs))
result = ResponseBody(True, '', func(self, *args, **kwargs))
self.cursor.close()
self.cursor = None
self.db.commit()
@@ -40,8 +52,8 @@ def SafeDatabaseOperation(func):
self.cursor = None
self.db.rollback()
if cfg.others.debug:
logging.exception(e)
return (False, str(e), None)
LOGGER.exception(e)
return ResponseBody(False, str(e), None)
return wrapper
@@ -182,7 +194,12 @@ class CalendarDatabase:
@SafeDatabaseOperation
def common_webLogin(self, username, password, clientUa, clientIp):
self.cursor.execute('SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;', (username, utils.ComputePasswordHash(password)))
LOGGER.debug(f'WebLogin Username: {username}')
LOGGER.debug(f'WebLogin Password: {password}')
passwordHash = utils.ComputePasswordHash(password)
LOGGER.debug(f'WebLogin Password Hash: {passwordHash}')
self.cursor.execute('SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;', (username, passwordHash))
if len(self.cursor.fetchall()) != 0:
token = utils.GenerateToken(username)
@@ -553,8 +570,8 @@ class CalendarDatabase:
argumentsList.append(_username)
self.cursor.execute('UPDATE user SET {} WHERE [name] = ?;'.format(', '.join(sqlList)),
tuple(argumentsList))
logging.debug(cache)
logging.debug(tuple(argumentsList))
LOGGER.debug(cache)
LOGGER.debug(tuple(argumentsList))
if self.cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.')
return True

42
backend/logger.py Normal file
View File

@@ -0,0 +1,42 @@
import logging
import enum
def _build_logger() -> tuple[logging.Logger, logging.Handler]:
# Create a new logger which is independent with Flask
logger = logging.getLogger("my_console_logger")
# Avoid message was propagated to root logger or captured by Flask logger.
logger.propagate = False
# Set initial level.
logger.setLevel(logging.INFO)
# Create StreamHandler to output into stderr.
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
# Set format for it.
formatter = logging.Formatter("[%(levelname)s] %(message)s")
console_handler.setFormatter(formatter)
# Add handler
logger.addHandler(console_handler)
return (logger, console_handler)
(LOGGER, CONSOLE_HANDLER) = _build_logger()
class LoggerLevel(enum.IntEnum):
DEBUG = enum.auto()
INFO = enum.auto()
def set_level(level: LoggerLevel) -> None:
logging_level: int = logging.INFO
match level:
case LoggerLevel.DEBUG:
logging_level = logging.DEBUG
case LoggerLevel.INFO:
logging_level = logging.INFO
LOGGER.setLevel(logging_level)
CONSOLE_HANDLER.setLevel(logging_level)

View File

@@ -1,8 +1,12 @@
from flask import Flask
from flask import request
from dataclasses import dataclass
from typing import Any, Callable
import config
import database
import utils
from logger import LOGGER
app = Flask(__name__)
calendar_db = database.CalendarDatabase()
@@ -14,7 +18,7 @@ calendar_db = database.CalendarDatabase()
@app.route('/common/salt', methods=['POST'])
def api_common_saltHandle():
return SmartDbCaller(calendar_db.common_salt,
(('username', str, False), ),
(FormField('username', str, False), ),
None)
@app.route('/common/login', methods=['POST'])
@@ -27,10 +31,10 @@ def api_common_loginHandle():
clientIp = request.remote_addr
return SmartDbCaller(calendar_db.common_login,
(('username', str, False),
('password', str, False),
('clientUa', str, False),
('clientIp', str, False)),
(FormField('username', str, False),
FormField('password', str, False),
FormField('clientUa', str, False),
FormField('clientIp', str, False)),
{
'clientUa': clientUa,
'clientIp': clientIp
@@ -44,12 +48,12 @@ def api_common_webLoginHandle():
clientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
clientIp = request.remote_addr
return SmartDbCaller(calendar_db.common_webLogin,
(('username', str, False),
('password', str, False),
('clientUa', str, False),
('clientIp', str, False)),
(FormField('username', str, False),
FormField('password', str, False),
FormField('clientUa', str, False),
FormField('clientIp', str, False)),
{
'clientUa': clientUa,
'clientIp': clientIp
@@ -58,13 +62,13 @@ def api_common_webLoginHandle():
@app.route('/common/logout', methods=['POST'])
def api_common_logoutHandle():
return SmartDbCaller(calendar_db.common_logout,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/common/tokenValid', methods=['POST'])
def api_common_tokenValidHandle():
return SmartDbCaller(calendar_db.common_tokenValid,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
# endregion
@@ -74,60 +78,60 @@ def api_common_tokenValidHandle():
@app.route('/calendar/getFull', methods=['POST'])
def api_calendar_getFullHandle():
return SmartDbCaller(calendar_db.calendar_getFull,
(('token', str, False),
('startDateTime', int, False),
('endDateTime', int, False)),
(FormField('token', str, False),
FormField('startDateTime', int, False),
FormField('endDateTime', int, False)),
None)
@app.route('/calendar/getList', methods=['POST'])
def api_calendar_getListHandle():
return SmartDbCaller(calendar_db.calendar_getList,
(('token', str, False),
('startDateTime', int, False),
('endDateTime', int, False)),
(FormField('token', str, False),
FormField('startDateTime', int, False),
FormField('endDateTime', int, False)),
None)
@app.route('/calendar/getDetail', methods=['POST'])
def api_calendar_getDetailHandle():
return SmartDbCaller(calendar_db.calendar_getDetail,
(('token', str, False),
('uuid', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False)),
None)
@app.route('/calendar/update', methods=['POST'])
def api_calendar_updateHandle():
return SmartDbCaller(calendar_db.calendar_update,
(('token', str, False),
('uuid', str, False),
('belongTo', str, True),
('title', str, True),
('description', str, True),
('eventDateTimeStart', int, True),
('eventDateTimeEnd', int, True),
('loopRules', str, True),
('timezoneOffset', int, True),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('belongTo', str, True),
FormField('title', str, True),
FormField('description', str, True),
FormField('eventDateTimeStart', int, True),
FormField('eventDateTimeEnd', int, True),
FormField('loopRules', str, True),
FormField('timezoneOffset', int, True),
FormField('lastChange', str, False)),
None)
@app.route('/calendar/add', methods=['POST'])
def api_calendar_addHandle():
return SmartDbCaller(calendar_db.calendar_add,
(('token', str, False),
('belongTo', str, False),
('title', str, False),
('description', str, False),
('eventDateTimeStart', int, False),
('eventDateTimeEnd', int, False),
('loopRules', str, False),
('timezoneOffset', int, False)),
(FormField('token', str, False),
FormField('belongTo', str, False),
FormField('title', str, False),
FormField('description', str, False),
FormField('eventDateTimeStart', int, False),
FormField('eventDateTimeEnd', int, False),
FormField('loopRules', str, False),
FormField('timezoneOffset', int, False)),
None)
@app.route('/calendar/delete', methods=['POST'])
def api_calendar_deleteHandle():
return SmartDbCaller(calendar_db.calendar_delete,
(('token', str, False),
('uuid', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('lastChange', str, False)),
None)
# endregion
@@ -137,77 +141,77 @@ def api_calendar_deleteHandle():
@app.route('/collection/getFullOwn', methods=['POST'])
def api_collection_getFullOwnHandle():
return SmartDbCaller(calendar_db.collection_getFullOwn,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/collection/getListOwn', methods=['POST'])
def api_collection_getListOwnHandle():
return SmartDbCaller(calendar_db.collection_getListOwn,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/collection/getDetailOwn', methods=['POST'])
def api_collection_getDetailOwnHandle():
return SmartDbCaller(calendar_db.collection_getDetailOwn,
(('token', str, False),
('uuid', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False)),
None)
@app.route('/collection/addOwn', methods=['POST'])
def api_collection_addOwnHandle():
return SmartDbCaller(calendar_db.collection_addOwn,
(('token', str, False),
('name', str, False)),
(FormField('token', str, False),
FormField('name', str, False)),
None)
@app.route('/collection/updateOwn', methods=['POST'])
def api_collection_updateOwnHandle():
return SmartDbCaller(calendar_db.collection_updateOwn,
(('token', str, False),
('uuid', str, False),
('name', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('name', str, False),
FormField('lastChange', str, False)),
None)
@app.route('/collection/deleteOwn', methods=['POST'])
def api_collection_deleteOwnHandle():
return SmartDbCaller(calendar_db.collection_deleteOwn,
(('token', str, False),
('uuid', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('lastChange', str, False)),
None)
@app.route('/collection/getSharing', methods=['POST'])
def api_collection_getSharingHandle():
return SmartDbCaller(calendar_db.collection_getSharing,
(('token', str, False),
('uuid', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False)),
None)
@app.route('/collection/deleteSharing', methods=['POST'])
def api_collection_deleteSharingHandle():
return SmartDbCaller(calendar_db.collection_deleteSharing,
(('token', str, False),
('uuid', str, False),
('target', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('target', str, False),
FormField('lastChange', str, False)),
None)
@app.route('/collection/addSharing', methods=['POST'])
def api_collection_addSharingHandle():
return SmartDbCaller(calendar_db.collection_addSharing,
(('token', str, False),
('uuid', str, False),
('target', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('target', str, False),
FormField('lastChange', str, False)),
None)
@app.route('/collection/getShared', methods=['POST'])
def api_collection_getSharedHandle():
return SmartDbCaller(calendar_db.collection_getShared,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
# endregion
@@ -217,43 +221,43 @@ def api_collection_getSharedHandle():
@app.route('/todo/getFull', methods=['POST'])
def api_todo_getFullHandle():
return SmartDbCaller(calendar_db.todo_getFull,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/todo/getList', methods=['POST'])
def api_todo_getListHandle():
return SmartDbCaller(calendar_db.todo_getList,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/todo/getDetail', methods=['POST'])
def api_todo_getDetailHandle():
return SmartDbCaller(calendar_db.todo_getDetail,
(('token', str, False),
('uuid', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False)),
None)
@app.route('/todo/add', methods=['POST'])
def api_todo_addHandle():
return SmartDbCaller(calendar_db.todo_add,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/todo/update', methods=['POST'])
def api_todo_updateHandle():
return SmartDbCaller(calendar_db.todo_update,
(('token', str, False),
('uuid', str, False),
('data', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('data', str, False),
FormField('lastChange', str, False)),
None)
@app.route('/todo/delete', methods=['POST'])
def api_todo_deleteHandle():
return SmartDbCaller(calendar_db.todo_delete,
(('token', str, False),
('uuid', str, False),
('lastChange', str, False)),
(FormField('token', str, False),
FormField('uuid', str, False),
FormField('lastChange', str, False)),
None)
# endregion
@@ -263,30 +267,30 @@ def api_todo_deleteHandle():
@app.route('/admin/get', methods=['POST'])
def api_admin_getHandle():
return SmartDbCaller(calendar_db.admin_get,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/admin/add', methods=['POST'])
def api_admin_addHandle():
return SmartDbCaller(calendar_db.admin_add,
(('token', str, False),
('username', str, False)),
(FormField('token', str, False),
FormField('username', str, False)),
None)
@app.route('/admin/update', methods=['POST'])
def api_admin_updateHandle():
return SmartDbCaller(calendar_db.admin_update,
(('token', str, False),
('username', str, False),
('password', str, True),
('isAdmin', utils.Str2Bool, True)),
(FormField('token', str, False),
FormField('username', str, False),
FormField('password', str, True),
FormField('isAdmin', utils.Str2Bool, True)),
None)
@app.route('/admin/delete', methods=['POST'])
def api_admin_deleteHandle():
return SmartDbCaller(calendar_db.admin_delete,
(('token', str, False),
('username', str, False)),
(FormField('token', str, False),
FormField('username', str, False)),
None)
# endregion
@@ -296,27 +300,27 @@ def api_admin_deleteHandle():
@app.route('/profile/isAdmin', methods=['POST'])
def api_profile_isAdminHandle():
return SmartDbCaller(calendar_db.profile_isAdmin,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/profile/changePassword', methods=['POST'])
def api_profile_changePasswordHandle():
return SmartDbCaller(calendar_db.profile_changePassword,
(('token', str, False),
('password', str, False)),
(FormField('token', str, False),
FormField('password', str, False)),
None)
@app.route('/profile/getToken', methods=['POST'])
def api_profile_getTokenHandle():
return SmartDbCaller(calendar_db.profile_getToken,
(('token', str, False), ),
(FormField('token', str, False), ),
None)
@app.route('/profile/deleteToken', methods=['POST'])
def api_profile_deleteTokenHandle():
return SmartDbCaller(calendar_db.profile_deleteToken,
(('token', str, False),
('deleteToken', str, False)),
(FormField('token', str, False),
FormField('deleteToken', str, False)),
None)
# endregion
@@ -325,41 +329,64 @@ def api_profile_deleteTokenHandle():
# region: Misc Functions
def SmartDbCaller(dbMethod, paramTuple, extraDict):
result = (False, 'Invalid parameter', None)
optCount = 0
paramList = []
optParamDict = {}
# for each item,
# item[0] is field name.
# item[1] is type.
# item[2] is whether it is optional field
realForm = request.form.to_dict()
if extraDict is not None:
realForm.update(extraDict)
for item in paramTuple:
cache = item[1](realForm.get(item[0], None))
if item[2]:
# optional param
if cache is not None:
optParamDict[item[0]] = cache
optCount += 1
else:
if cache is None:
break
paramList.append(cache)
else:
# at least one opt param
if optCount == 0 or len(optParamDict) != 0:
result = dbMethod(*paramList, **optParamDict)
@dataclass(frozen=True)
class FormField:
name: str
"""The name of form field."""
ty: Callable[[str], Any]
"""The type of form field."""
is_optional: bool
"""True if this form field is optional, otherwise false."""
def SmartDbCaller(db_method: Callable, fields: tuple[FormField, ...], padding_form: dict[str, Any] | None) -> dict[str, Any]:
result = ResponseBody(False, 'Invalid parameter', None)
opt_param_counter = 0
param_list: list[Any] = []
opt_param_dict: dict[str, Any] = {}
real_form = request.form.to_dict()
LOGGER.debug(f'Form: {real_form}')
if padding_form is not None:
real_form.update(padding_form)
for field in fields:
value = real_form.get(field.name, None)
if value is not None:
value = field.ty(value)
if field.is_optional:
# optional param
if value is not None:
opt_param_dict[field.name] = value
opt_param_counter += 1
else:
# required param
if value is None:
break
param_list.append(value)
# at least one opt param
LOGGER.debug(f'All Optional Parameter: {opt_param_counter}')
LOGGER.debug(f'Optional Parameter Count: {len(opt_param_dict)}')
if opt_param_counter == 0 or len(opt_param_dict) != 0:
result: ResponseBody = db_method(*param_list, **opt_param_dict)
return ConstructResponseBody(result)
def ConstructResponseBody(returnedTuple):
@dataclass(frozen=True)
class ResponseBody:
success: bool
"""True if this operation is successful, otherwise false."""
error: str
"""The error message provided when operation failed."""
data: Any
"""The payload provided when operation successed."""
def ConstructResponseBody(body: ResponseBody) -> dict[str, Any]:
return {
'success': returnedTuple[0],
'error': returnedTuple[1],
'data': returnedTuple[2]
'success': body.success,
'error': body.error,
'data': body.data
}
def run():