1
0

16 Commits
v1.1 ... master

Author SHA1 Message Date
c4b68400b3 feat: move old scripts from my gist to here 2026-06-01 23:27:41 +08:00
d7df194a12 feat: add image for legacy frontend 2026-06-01 23:26:45 +08:00
35fee0f473 refactor: modify backend for better understand 2026-05-15 11:08:57 +08:00
e484ded5be feat: add logout for navbar 2026-05-15 09:39:15 +08:00
2a280dcba0 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)
2026-05-14 21:13:13 +08:00
6337ae432d feat: add messagebox component 2026-05-14 10:36:03 +08:00
826cbf18b1 feat: add login page 2026-05-13 13:20:23 +08:00
167c83f7d4 feat: disable navigation to some pages if there is no user logged in 2026-05-13 12:46:11 +08:00
078e61e993 feat: add persist feature for frontend 2026-05-13 11:02:35 +08:00
bdee3b3efa feat: update database fields 2026-05-12 19:25:31 +08:00
37b08927a7 update frontend with bulma css and home&404 pages 2026-05-12 15:32:30 +08:00
46f2d69800 feat: modify vite config for backend frontend decouple dev 2026-05-11 22:34:16 +08:00
24790d8e69 feat: add nginx router config file for backend and frontend 2026-05-11 22:28:08 +08:00
07205396c8 refactor: change backend as the pure API backend 2026-05-11 22:20:06 +08:00
433c6cf2f8 doc: add roadmap for refactor 2026-05-06 12:19:56 +08:00
37435eeb66 refactor: introduce modern frontend 2026-04-28 15:47:32 +08:00
99 changed files with 6039 additions and 605 deletions

10
ROADMAP.md Normal file
View File

@@ -0,0 +1,10 @@
# Roadmap
1. 前后端分离将前端静态文件和后端Python分装到两个文件夹中。同时辅助类文件夹改换位置。
1. 后端使用Astral UV重构使得项目可以跑起来。
1. 定为1.1版本。1.0版本也要打tag然后提交。然后分叉v1-maintain分支。后续在master上开发v2。
1. 后端数据库字段重命名。
1. 前后端通信API命名格式修改。
1. 使用Vue重写前端
1. 使用Tailwind重写前端CSS
1. 使用Go重写后端。

61
assets/.nginx.conf Normal file
View File

@@ -0,0 +1,61 @@
# ============================
# 路由 1: /web -> 静态文件
# ============================
location /web {
# 使用 alias 精确映射
# 请求 /web/index.html -> /var/www/static/index.html
alias /var/www/static;
# 静态文件优化
expires 7d;
add_header Cache-Control "public, max-age=604800";
# 尝试返回文件不存在则返回404避免落入其他location
try_files $uri $uri/ =404;
# 可选:启用 gzip 压缩
gzip_static on;
}
# ============================
# 路由 2: /api -> Go 程序 (8848端口)
# ============================
location /api {
# 反向代理到本地 Go 服务
proxy_pass http://127.0.0.1:8848;
# 重要:保留原始请求头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持(如果 Go 程序需要)
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# 超时设置(根据业务调整)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置(可选,大文件上传时注意调整)
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# ============================
# 可选:根路径处理
# ============================
location = / {
# 重定向到 /web
return 302 /web/;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
return 404;
}

101
assets/ics2csv.py Normal file
View File

@@ -0,0 +1,101 @@
import icalendar
def DumpComponentHeader(file, component):
fields = []
inclusiveUnitCounter = 0
fields += map(lambda x: x + ' - required', component.required)
fields += map(lambda x: x + ' - singletons', component.singletons)
for units in component.inclusive:
fields += map(lambda x: x + ' - inclusive{}'.format(inclusiveUnitCounter), units)
inclusiveUnitCounter += 1
fields += ('exclusive - name', 'exclusive - data')
fields += ('multiple', )
file.write(','.join(fields))
file.write('\n')
def DumpComponentData(file, component):
data = []
gotten_instance = None
for item in component.required:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
for item in component.singletons:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
for units in component.inclusive:
for item in units:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
gotten_name = ""
gotten_data = ""
for item in component.exclusive:
gotten_instance = component.get(item)
if gotten_instance is not None:
gotten_name = item
gotten_data = AdvancedFormater(gotten_instance)
break
data.append(gotten_name)
data.append(gotten_data)
for item in component.multiple:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append('- {} -'.format(item))
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
file.write(','.join(data))
file.write('\n')
def AdvancedFormater(data):
if isinstance(data, icalendar.prop.vDDDTypes):
return str(data.dt)
else:
return str(data)
# read file
icsFile = open('test.ics', 'rb')
cal = icalendar.Calendar.from_ical(icsFile.read())
icsFile.close()
# analyse file
csvEvent = open('event.csv', 'w')
csvEventHeader = False
csvAlarm = open('alarm.csv', 'w')
csvAlarmHeader = False
eventCount = 0
alarmCount = 0
miscCount = 0
for component in cal.walk():
if component.name == 'VEVENT':
eventCount += 1
if not csvEventHeader:
DumpComponentHeader(csvEvent, component)
csvEventHeader = True
DumpComponentData(csvEvent, component)
elif component.name == 'VALARM':
alarmCount += 1
if not csvAlarmHeader:
DumpComponentHeader(csvAlarm, component)
csvAlarmHeader = True
DumpComponentData(csvAlarm, component)
else:
miscCount += 1
csvEvent.close()
csvAlarm.close()
print('Event count: {}\nAlarm count: {}\nMisc count: {}'.format(eventCount, alarmCount, miscCount))

156
assets/ics_converter.py Normal file
View File

@@ -0,0 +1,156 @@
import icalendar
import sys
import os
import database
import json
import datetime
import dt as localdt
def AdvancedDatetTimeGet(dt, isStartDateTime):
if isinstance(dt, datetime.datetime):
gottenDatetime = int(dt.timestamp() / 60)
elif isinstance(dt, datetime.date):
gottenDatetime = int(datetime.datetime(
dt.year,
dt.month,
dt.day,
0 if isStartDateTime else 23,
0 if isStartDateTime else 59,
0 if isStartDateTime else 59,
0, tzinfo=LOCAL_TZ
).timestamp() / 60)
else:
raise Exception('Unexpected data')
timezoneOffset = LOCAL_UTC_OFFSET
return (gottenDatetime, timezoneOffset)
def AdvancedDateTimeAnalyser(component):
startDatetimeRef = component.get('DTSTART').dt
(startDatetime, timezoneOffset) = AdvancedDatetTimeGet(startDatetimeRef, True)
if component.get('DTEND') is not None:
(endDatetime, _) = AdvancedDatetTimeGet(startDatetimeRef, False)
elif component.get('DURATION') is not None:
endDurationRef = component.get('DURATION').dt
if isinstance(endDurationRef, datetime.timedelta):
endDatetime = startDatetime + int(endDurationRef.total_seconds() / 60)
else:
raise Exception('Unexpected data')
else:
raise Exception('Unexpected data')
return (startDatetime, endDatetime, timezoneOffset)
def LoopRulesConverter(component):
jsonData = component.get('RRULE')
if jsonData is None:
return ""
loopRules = ""
loopStopRules = ""
freq = jsonData.get('FREQ')[0]
if freq == 'MONTHLY':
loopRules = 'MSA{}'.format(str(jsonData.get('INTERVAL')[0]))
elif freq == 'WEEKLY':
occupiedWeek = [False, ] * 7
for item in jsonData.get('BYDAY'):
occupiedWeek[WEEK_DICT[item]] = True
loopRules = 'W{}{}'.format(
''.join(map(lambda x: 'T' if x else 'F', occupiedWeek)),
str(jsonData.get('INTERVAL')[0])
)
elif freq == 'YEARLY':
loopRules = 'YS{}'.format(str(jsonData.get('INTERVAL')[0]))
else:
raise Exception('Unexpected data')
if jsonData.get('COUNT') is not None:
loopStopRules = 'T{}'.format(str(jsonData.get('COUNT')[0]))
else:
loopStopRules = 'F'
return loopRules + '-' + loopStopRules
# ============================ read args
icsFilePath = sys.argv[1]
if not os.path.isfile(icsFilePath):
print('Fail to load ics file')
sys.exit(1)
# read file
icsFile = open(icsFilePath, 'rb')
cal = icalendar.Calendar.from_ical(icsFile.read())
icsFile.close()
# ============================ init const
utfOffset = float(input('Input this ics file\'s utc offset (time unit: hour)>'))
LOCAL_UTC_OFFSET = int(utfOffset * 60)
LOCAL_TZ = localdt.UTCTimezone(LOCAL_UTC_OFFSET)
WEEK_DICT = {
"SU": 6, "MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5,
}
# ============================ pick database
db = database.CalendarDatabase()
db.open()
username = input('Input username >')
password = input('Input password >')
(status, error, token) = db.common_webLogin(username, password, 'Python backend', '127.0.0.1')
if not status:
print('Fail to login.')
sys.exit(1)
(status, error, collectionList) = db.collection_getFullOwn(token)
if not status:
print('Database return an error')
sys.exit(1)
print('Pick a collection to insert imported events')
counter = 0
for i in collectionList:
print('{}\t{}'.format(counter, i[1]))
counter += 1
pickedIndex = int(input())
collectionUuid = collectionList[pickedIndex][0]
# ============================ analyse file
eventCount = 0
allCount = 0
for component in cal.walk():
allCount += 1
# only import event chunk
if component.name == 'VEVENT':
eventCount += 1
title = str(component.get('SUMMARY'))
descriptionPrototype = {
'color': '#1e90ff',
'description': None
}
descriptionList = []
if component.get('DESCRIPTION') is not None and str(component.get('DESCRIPTION')) != '':
descriptionList.append(component.get('DESCRIPTION'))
if component.get('LOCATION') is not None and str(component.get('LOCATION')) != '':
descriptionList.append(component.get('LOCATION'))
descriptionPrototype['description'] = '\n'.join(descriptionList)
description = json.dumps(descriptionPrototype)
(eventDateTimeStart, eventDateTimeEnd, timezoneOffset) = AdvancedDateTimeAnalyser(component)
loopRules = LoopRulesConverter(component)
(status, _, _) = db.calendar_add(
token,
collectionUuid,
title,
description,
eventDateTimeStart,
eventDateTimeEnd,
loopRules,
timezoneOffset
)
if not status:
print('Database return an error')
sys.exit(1)
db.common_logout(token)
db.close()
print('All chunk: {}\nEvent count: {}'.format(allCount, eventCount))

View File

@@ -0,0 +1,3 @@
# Migration
This directory contains the migration scripts for the database.

View File

@@ -0,0 +1,49 @@
-- Migration script for coconut-leaf database v1 to v2
-- This script updates field names by:
-- 1. Removing 'ccn_' prefix from all fields
-- 2. Converting camelCase to snake_case
-- Step 1: Rename user table columns
ALTER TABLE user RENAME COLUMN ccn_name TO name;
ALTER TABLE user RENAME COLUMN ccn_password TO password;
ALTER TABLE user RENAME COLUMN ccn_isAdmin TO is_admin;
ALTER TABLE user RENAME COLUMN ccn_salt TO salt;
-- Step 2: Rename token table columns
ALTER TABLE token RENAME COLUMN ccn_user TO user;
ALTER TABLE token RENAME COLUMN ccn_token TO token;
ALTER TABLE token RENAME COLUMN ccn_tokenExpireOn TO token_expire_on;
ALTER TABLE token RENAME COLUMN ccn_ua TO ua;
ALTER TABLE token RENAME COLUMN ccn_ip TO ip;
-- Step 3: Rename collection table columns
ALTER TABLE collection RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE collection RENAME COLUMN ccn_name TO name;
ALTER TABLE collection RENAME COLUMN ccn_user TO user;
ALTER TABLE collection RENAME COLUMN ccn_lastChange TO last_change;
-- Step 4: Rename share table columns
ALTER TABLE share RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE share RENAME COLUMN ccn_target TO target;
-- Step 5: Rename calendar table columns
ALTER TABLE calendar RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE calendar RENAME COLUMN ccn_belongTo TO belong_to;
ALTER TABLE calendar RENAME COLUMN ccn_title TO title;
ALTER TABLE calendar RENAME COLUMN ccn_description TO description;
ALTER TABLE calendar RENAME COLUMN ccn_lastChange TO last_change;
ALTER TABLE calendar RENAME COLUMN ccn_eventDateTimeStart TO event_date_time_start;
ALTER TABLE calendar RENAME COLUMN ccn_eventDateTimeEnd TO event_date_time_end;
ALTER TABLE calendar RENAME COLUMN ccn_timezoneOffset TO timezone_offset;
ALTER TABLE calendar RENAME COLUMN ccn_loopRules TO loop_rules;
ALTER TABLE calendar RENAME COLUMN ccn_loopDateTimeStart TO loop_date_time_start;
ALTER TABLE calendar RENAME COLUMN ccn_loopDateTimeEnd TO loop_date_time_end;
-- Step 6: Rename todo table columns
ALTER TABLE todo RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE todo RENAME COLUMN ccn_belongTo TO belong_to;
ALTER TABLE todo RENAME COLUMN ccn_data TO data;
ALTER TABLE todo RENAME COLUMN ccn_lastChange TO last_change;
-- Note: Foreign key constraints will be automatically updated by SQLite when renaming columns
-- No additional steps needed for foreign keys

View File

@@ -1,15 +1,17 @@
import sys import sys
import logging
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import cast from typing import cast
from pathlib import Path from pathlib import Path
import server import server
import config import config
import utils import utils
import database import database
import logger
from logger import LOGGER, LoggerLevel
def GetUsernamePassword(): def GetUsernamePassword() -> tuple[str, str]:
print("What is the first username of this calendar system?") print("What is the first username of this calendar system?")
cache = input() cache = input()
while not utils.IsValidUsername(cache): while not utils.IsValidUsername(cache):
@@ -26,15 +28,15 @@ def GetUsernamePassword():
return (username, password) return (username, password)
if __name__ == "__main__": if __name__ == "__main__":
print("Coconut-leaf") # Set as INFO level in default first,
print("A self-host, multi-account calendar system.") # and we will change it once we load the configuration file.
print("Project: https://github.com/yyc12345/coconut-leaf") logger.set_level(LoggerLevel.INFO)
print("===================")
# Receive arguments # Receive arguments
parser = ArgumentParser(description="Coconut-leaf") parser = ArgumentParser(
description="The server of light, self-host and multi-account calendar system."
)
parser.add_argument( parser.add_argument(
"-c", "-c",
"--config", "--config",
@@ -54,16 +56,22 @@ if __name__ == "__main__":
) )
args = parser.parse_args() args = parser.parse_args()
# Show splash
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 # Load config file
try: try:
config.setup_config(cast(Path, args.config)) config.setup_config(cast(Path, args.config))
except Exception as e: except Exception as e:
print(f"Error loading config file: {e}") LOGGER.critical(f"Error loading config file: {e}")
sys.exit(1) sys.exit(1)
# Setup logging level # Change logging level again according to whether enable debug mode
logging_level = logging.DEBUG if config.get_config().others.debug else logging.INFO logging_level = LoggerLevel.DEBUG if config.get_config().others.debug else LoggerLevel.INFO
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging_level) logger.set_level(logging_level)
# Initialize the calendar system if needed # Initialize the calendar system if needed
if cast(bool, args.init): if cast(bool, args.init):
@@ -72,5 +80,5 @@ if __name__ == "__main__":
calendar.init(*gotten_data) calendar.init(*gotten_data)
calendar.close() calendar.close()
logging.info("Staring server...") LOGGER.info("Staring server...")
server.run() server.run()

View File

@@ -15,7 +15,7 @@ path = "coconut-leaf.db"
# database = "coconut_leaf" # database = "coconut_leaf"
[web] [web]
port = 8888 port = 8848
[others] [others]
auto-token-clean-duration = 86400 auto-token-clean-duration = 86400

View File

@@ -1,14 +0,0 @@
{
"database-type": "sqlite",
"database-config": {
"user": "",
"password": "",
"db": "",
"url": "",
"port": 3306
},
"web": {
"port": 8888
},
"debug": true
}

View File

@@ -1,54 +1,81 @@
import config
import sqlite3 import sqlite3
import utils
import threading import threading
import logging
import dt
from typing import cast from typing import cast
from pathlib import Path from pathlib import Path
from dataclasses import dataclass
from typing import Callable, ParamSpec, TypeVar, Generic
def SafeDatabaseOperation(func): import dt
def wrapper(self: 'CalendarDatabase', *args, **kwargs): import utils
import config
from logger import LOGGER
T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')
@dataclass(frozen=True)
class ResponseBody(Generic[T]):
"""The generic response body for API return."""
success: bool
"""True if this operation is successful, otherwise false."""
error: str
"""The error message provided when operation failed."""
data: T | None
"""The payload provided when operation successed."""
class DbException(Exception):
"""Error occurs when manipulating with database."""
pass
def SafeDatabaseOperation(inner: Callable[P, R]) -> Callable[P, ResponseBody[R]]:
def wrapper(*args, **kwargs) -> ResponseBody[R]:
# extract self from args
self: 'CalendarDatabase' = args[0]
# get config
cfg = config.get_config() cfg = config.get_config()
with self.mutex: with self.mutex:
# check database and acquire cursor # try to fetching database and allocate database cursor
try: try:
self.check_database() db = self._get_db()
self.cursor = self.db.cursor() self._allocate_cursor()
except Exception as e: except Exception as e:
self.cursor = None self._free_cursor()
if cfg.others.debug: if cfg.others.debug:
logging.exception(e) LOGGER.exception(e)
return (False, str(e), None) return ResponseBody(False, str(e), None)
# do real data work # do real data work
try: try:
currentTime = utils.GetCurrentTimestamp() currentTime = utils.GetCurrentTimestamp()
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration: if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
self.latestClean = currentTime self.latestClean = currentTime
logging.info('Cleaning outdated token...') LOGGER.info('Cleaning outdated token...')
self.tokenOper_clean() self.tokenOper_clean()
result = (True, '', func(self, *args, **kwargs)) result = ResponseBody(True, '', inner(*args, **kwargs))
self.cursor.close() self._free_cursor()
self.cursor = None db.commit()
self.db.commit()
return result return result
except Exception as e: except Exception as e:
self.cursor.close() self._free_cursor()
self.cursor = None db.rollback()
self.db.rollback()
if cfg.others.debug: if cfg.others.debug:
logging.exception(e) LOGGER.exception(e)
return (False, str(e), None) return ResponseBody(False, str(e), None)
return wrapper return wrapper
class CalendarDatabase: class CalendarDatabase:
db: sqlite3.Connection db: sqlite3.Connection | None
cursor: sqlite3.Cursor cursor: sqlite3.Cursor | None
mutex: threading.Lock mutex: threading.Lock
latestClean: int latestClean: int
@@ -59,8 +86,8 @@ class CalendarDatabase:
self.latestClean = 0 self.latestClean = 0
def open(self): def open(self):
if (self.is_database_valid()): if (self.db is not None):
raise Exception('Databade is opened') raise DbException('Database is already opened')
cfg = config.get_config() cfg = config.get_config()
match cfg.database.driver: match cfg.database.driver:
@@ -69,13 +96,13 @@ class CalendarDatabase:
self.db.execute('PRAGMA encoding = "UTF-8";') self.db.execute('PRAGMA encoding = "UTF-8";')
self.db.execute('PRAGMA foreign_keys = ON;') self.db.execute('PRAGMA foreign_keys = ON;')
case config.DatabaseDriver.MYSQL: case config.DatabaseDriver.MYSQL:
raise Exception('Not implemented database') raise DbException('Not implemented database')
case _: case _:
raise Exception('Unknow database type') raise DbException('Unknow database type')
def init(self, username, password): def init(self, username: str, password: str):
if (self.is_database_valid()): if (self.db is not None):
raise Exception('Database is opened') raise DbException('Database is already opened')
# establish tables # establish tables
cfg = config.get_config() cfg = config.get_config()
@@ -85,44 +112,74 @@ class CalendarDatabase:
case config.DatabaseDriver.SQLITE: case config.DatabaseDriver.SQLITE:
sql_file = backend_sql_path / 'sqlite.sql' sql_file = backend_sql_path / 'sqlite.sql'
case config.DatabaseDriver.MYSQL: case config.DatabaseDriver.MYSQL:
raise Exception('Not implemented database') raise DbException('Not implemented database')
case _: case _:
raise Exception('Unknow database type') raise DbException('Unknow database type')
self.open() self.open()
cursor = self.db.cursor() db = self._get_db()
self._allocate_cursor()
cursor = self._get_cursor()
# execute script for creating tables
with open(sql_file, 'r', encoding='utf-8') as fsql: with open(sql_file, 'r', encoding='utf-8') as fsql:
cursor.executescript(fsql.read()) cursor.executescript(fsql.read())
# add default user in user table
# finish init
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', ( cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
username, username,
utils.ComputePasswordHash(password), utils.ComputePasswordHash(password),
1, 1,
utils.GenerateSalt() utils.GenerateSalt()
)) ))
cursor.close()
self.db.commit() self._free_cursor()
# commit to database
db.commit()
def close(self): def close(self):
self.check_database() if (self.db is None):
self.db.close() LOGGER.warning('Try to close null database.')
self.db = None else:
self._free_cursor()
self.db.close()
self.db = None
def check_database(self): def _get_db(self) -> sqlite3.Connection:
if (not self.is_database_valid()): if (self.db is None):
raise Exception('Databade is None') raise DbException('There is no opened database')
else:
return self.db
def is_database_valid(self): def _allocate_cursor(self) -> None:
return not (self.db == None) if (self.cursor is not None):
raise DbException('There is already opened database cursor')
else:
self.cursor = self._get_db().cursor()
def _get_cursor(self) -> sqlite3.Cursor:
if (self.cursor is None):
raise DbException('There is no opened database cursor')
else:
return self.cursor
def _free_cursor(self) -> None:
if (self.cursor is None):
LOGGER.warning('Try to free null databse cursor.')
else:
self.cursor.close()
self.cursor = None
# ======================= token related internal operation # ======================= token related internal operation
def tokenOper_clean(self): def tokenOper_clean(self):
# remove outdated token # remove outdated token
self.cursor.execute('DELETE FROM token WHERE [ccn_tokenExpireOn] <= ?',(utils.GetCurrentTimestamp(), )) cursor = self._get_cursor()
cursor.execute('DELETE FROM token WHERE [token_expire_on] <= ?',(utils.GetCurrentTimestamp(), ))
def tokenOper_postpone_expireOn(self, token): def tokenOper_postpone_expireOn(self, token):
self.cursor.execute('UPDATE token SET [ccn_tokenExpireOn] = ? WHERE [ccn_token] = ?;', ( cursor = self._get_cursor()
cursor.execute('UPDATE token SET [token_expire_on] = ? WHERE [token] = ?;', (
utils.GetTokenExpireOn(), utils.GetTokenExpireOn(),
token token
)) ))
@@ -131,16 +188,18 @@ class CalendarDatabase:
self.tokenOper_get_username(token) self.tokenOper_get_username(token)
def tokenOper_is_admin(self, username): def tokenOper_is_admin(self, username):
self.cursor.execute('SELECT [ccn_isAdmin] FROM user WHERE [ccn_name] = ?;',(username, )) cursor = self._get_cursor()
cache = self.cursor.fetchone()[0] cursor.execute('SELECT [is_admin] FROM user WHERE [name] = ?;',(username, ))
cache = cursor.fetchone()[0]
return cache == 1 return cache == 1
def tokenOper_get_username(self, token): def tokenOper_get_username(self, token):
self.cursor.execute('SELECT [ccn_user] FROM token WHERE [ccn_token] = ? AND [ccn_tokenExpireOn] > ?;',( cursor = self._get_cursor()
cursor.execute('SELECT [user] FROM token WHERE [token] = ? AND [token_expire_on] > ?;',(
token, token,
utils.GetCurrentTimestamp() utils.GetCurrentTimestamp()
)) ))
result = self.cursor.fetchone()[0] result = cursor.fetchone()[0]
# need postpone expire on time # need postpone expire on time
self.tokenOper_postpone_expireOn(token) self.tokenOper_postpone_expireOn(token)
return result return result
@@ -150,8 +209,9 @@ class CalendarDatabase:
@SafeDatabaseOperation @SafeDatabaseOperation
def common_salt(self, username): def common_salt(self, username):
cursor = self._get_cursor()
salt = utils.GenerateSalt() salt = utils.GenerateSalt()
self.cursor.execute('UPDATE user SET [ccn_salt] = ? WHERE [ccn_name] = ?;', ( cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
salt, salt,
username username
)) ))
@@ -159,16 +219,17 @@ class CalendarDatabase:
@SafeDatabaseOperation @SafeDatabaseOperation
def common_login(self, username, password, clientUa, clientIp): def common_login(self, username, password, clientUa, clientIp):
self.cursor.execute('SELECT [ccn_password], [ccn_salt] FROM user WHERE [ccn_name] = ?;', (username, )) cursor = self._get_cursor()
(gotten_salt, gotten_password) = self.cursor.fetchone() cursor.execute('SELECT [password], [salt] FROM user WHERE [name] = ?;', (username, ))
(gotten_salt, gotten_password) = cursor.fetchone()
if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt): if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt):
token = utils.GenerateToken(username) token = utils.GenerateToken(username)
self.cursor.execute('UPDATE user SET [ccn_salt] = ? WHERE [ccn_name] = ?;', ( cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
utils.GenerateSalt(), # regenerate a new slat to prevent re-login try utils.GenerateSalt(), # regenerate a new slat to prevent re-login try
username username
)) ))
self.cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', ( cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
username, username,
token, token,
utils.GetTokenExpireOn(), # add 2 day from now utils.GetTokenExpireOn(), # add 2 day from now
@@ -178,15 +239,21 @@ class CalendarDatabase:
return token return token
else: else:
# throw a exception to indicate fail to login # throw a exception to indicate fail to login
raise Exception('Login authentication failed') raise DbException('Login authentication failed')
@SafeDatabaseOperation @SafeDatabaseOperation
def common_webLogin(self, username, password, clientUa, clientIp): def common_webLogin(self, username, password, clientUa, clientIp):
self.cursor.execute('SELECT [ccn_name] FROM user WHERE [ccn_name] = ? AND [ccn_password] = ?;', (username, utils.ComputePasswordHash(password))) cursor = self._get_cursor()
LOGGER.debug(f'WebLogin Username: {username}')
LOGGER.debug(f'WebLogin Password: {password}')
passwordHash = utils.ComputePasswordHash(password)
LOGGER.debug(f'WebLogin Password Hash: {passwordHash}')
if len(self.cursor.fetchall()) != 0: cursor.execute('SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;', (username, passwordHash))
if len(cursor.fetchall()) != 0:
token = utils.GenerateToken(username) token = utils.GenerateToken(username)
self.cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', ( cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
username, username,
token, token,
utils.GetTokenExpireOn(), # add 2 day from now utils.GetTokenExpireOn(), # add 2 day from now
@@ -196,12 +263,13 @@ class CalendarDatabase:
return token return token
else: else:
# throw a exception to indicate fail to login # throw a exception to indicate fail to login
raise Exception('Login authentication failed') raise DbException('Login authentication failed')
@SafeDatabaseOperation @SafeDatabaseOperation
def common_logout(self, token): def common_logout(self, token):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
self.cursor.execute('DELETE FROM token WHERE [ccn_token] = ?;', (token, )) cursor.execute('DELETE FROM token WHERE [token] = ?;', (token, ))
return True return True
@SafeDatabaseOperation @SafeDatabaseOperation
@@ -212,40 +280,44 @@ class CalendarDatabase:
# =============================== calendar # =============================== calendar
@SafeDatabaseOperation @SafeDatabaseOperation
def calendar_getFull(self, token, startDateTime, endDateTime): def calendar_getFull(self, token, startDateTime, endDateTime):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \ cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
ON collection.ccn_uuid = calendar.ccn_belongTo \ ON collection.uuid = calendar.belong_to \
WHERE (collection.ccn_user = ? AND calendar.ccn_loopDateTimeEnd >= ? AND calendar.ccn_loopDateTimeStart - (calendar.ccn_eventDateTimeEnd - calendar.ccn_eventDateTimeStart) <= ?);', WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
(username, startDateTime, endDateTime)) (username, startDateTime, endDateTime))
return self.cursor.fetchall() return cursor.fetchall()
@SafeDatabaseOperation @SafeDatabaseOperation
def calendar_getList(self, token, startDateTime, endDateTime): def calendar_getList(self, token, startDateTime, endDateTime):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT calendar.ccn_uuid FROM calendar INNER JOIN collection \ cursor.execute('SELECT calendar.uuid FROM calendar INNER JOIN collection \
ON collection.ccn_uuid = calendar.ccn_belongTo \ ON collection.uuid = calendar.belong_to \
WHERE (collection.ccn_user = ? AND calendar.ccn_loopDateTimeEnd >= ? AND calendar.ccn_loopDateTimeStart - (calendar.ccn_eventDateTimeEnd - calendar.ccn_eventDateTimeStart) <= ?);', WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
(username, startDateTime, endDateTime)) (username, startDateTime, endDateTime))
return tuple(map(lambda x: x[0], self.cursor.fetchall())) return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation @SafeDatabaseOperation
def calendar_getDetail(self, token, uuid): def calendar_getDetail(self, token, uuid):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
self.cursor.execute('SELECT * FROM calendar WHERE [ccn_uuid] = ?;', (uuid, )) cursor.execute('SELECT * FROM calendar WHERE [uuid] = ?;', (uuid, ))
return self.cursor.fetchone() return cursor.fetchone()
@SafeDatabaseOperation @SafeDatabaseOperation
def calendar_update(self, token, uuid, lastChange, **optArgs): def calendar_update(self, token, uuid, lastChange, **optArgs):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
# get prev data # get prev data
self.cursor.execute('SELECT * FROM calendar WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange)) cursor.execute('SELECT * FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
analyseData = list(self.cursor.fetchone()) analyseData = list(cursor.fetchone())
# construct update data # construct update data
lastupdate = utils.GenerateUUID() lastupdate = utils.GenerateUUID()
sqlList = [ sqlList = [
'[ccn_lastChange] = ?', '[last_change] = ?',
] ]
argumentsList = [ argumentsList = [
lastupdate, lastupdate,
@@ -256,44 +328,44 @@ class CalendarDatabase:
cache = optArgs.get('belongTo', None) cache = optArgs.get('belongTo', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_belongTo] = ?') sqlList.append('[belong_to] = ?')
argumentsList.append(cache) argumentsList.append(cache)
cache = optArgs.get('title', None) cache = optArgs.get('title', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_title] = ?') sqlList.append('[title] = ?')
argumentsList.append(cache) argumentsList.append(cache)
cache = optArgs.get('description', None) cache = optArgs.get('description', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_description] = ?') sqlList.append('[description] = ?')
argumentsList.append(cache) argumentsList.append(cache)
cache = optArgs.get('eventDateTimeStart', None) cache = optArgs.get('eventDateTimeStart', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_eventDateTimeStart] = ?') sqlList.append('[event_date_time_start] = ?')
argumentsList.append(cache) argumentsList.append(cache)
reAnalyseLoop = True reAnalyseLoop = True
analyseData[5] = cache analyseData[5] = cache
cache = optArgs.get('eventDateTimeEnd', None) cache = optArgs.get('eventDateTimeEnd', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_eventDateTimeEnd] = ?') sqlList.append('[event_date_time_end] = ?')
argumentsList.append(cache) argumentsList.append(cache)
cache = optArgs.get('loopRules', None) cache = optArgs.get('loopRules', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_loopRules] = ?') sqlList.append('[loop_rules] = ?')
argumentsList.append(cache) argumentsList.append(cache)
reAnalyseLoop = True reAnalyseLoop = True
analyseData[8] = cache analyseData[8] = cache
cache = optArgs.get('timezoneOffset', None) cache = optArgs.get('timezoneOffset', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_timezoneOffset] = ?') sqlList.append('[timezone_offset] = ?')
argumentsList.append(cache) argumentsList.append(cache)
reAnalyseLoop = True reAnalyseLoop = True
analyseData[7] = cache analyseData[7] = cache
if reAnalyseLoop: if reAnalyseLoop:
# re-compute loop data and upload it into list # re-compute loop data and upload it into list
sqlList.append('[ccn_loopDateTimeStart] = ?') sqlList.append('[loop_date_time_start] = ?')
argumentsList.append(analyseData[5]) argumentsList.append(analyseData[5])
sqlList.append('[ccn_loopDateTimeEnd] = ?') sqlList.append('[loop_date_time_end] = ?')
argumentsList.append(str(dt.ResolveLoopStr( argumentsList.append(str(dt.ResolveLoopStr(
analyseData[8], analyseData[8],
analyseData[5], analyseData[5],
@@ -302,14 +374,15 @@ class CalendarDatabase:
# execute # execute
argumentsList.append(uuid) argumentsList.append(uuid)
self.cursor.execute('UPDATE calendar SET {} WHERE [ccn_uuid] = ?;'.format(', '.join(sqlList)), cursor.execute('UPDATE calendar SET {} WHERE [uuid] = ?;'.format(', '.join(sqlList)),
tuple(argumentsList)) tuple(argumentsList))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.') raise DbException('Fail to update due to no matched rows or too much rows.')
return lastupdate return lastupdate
@SafeDatabaseOperation @SafeDatabaseOperation
def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset): def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
newuuid = utils.GenerateUUID() newuuid = utils.GenerateUUID()
@@ -319,7 +392,7 @@ class CalendarDatabase:
loopDateTimeStart = eventDateTimeStart loopDateTimeStart = eventDateTimeStart
loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset) loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset)
self.cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);', cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
(newuuid, (newuuid,
belongTo, belongTo,
title, title,
@@ -335,134 +408,149 @@ class CalendarDatabase:
@SafeDatabaseOperation @SafeDatabaseOperation
def calendar_delete(self, token, uuid, lastChange): def calendar_delete(self, token, uuid, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
self.cursor.execute('DELETE FROM calendar WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange)) cursor.execute('DELETE FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
return True return True
# =============================== collection # =============================== collection
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_getFullOwn(self, token): def collection_getFullOwn(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid], [ccn_name], [ccn_lastChange] FROM collection WHERE [ccn_user] = ?;', (username, )) cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ?;', (username, ))
return self.cursor.fetchall() return cursor.fetchall()
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_getListOwn(self, token): def collection_getListOwn(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid] FROM collection WHERE [ccn_user] = ?;', (username, )) cursor.execute('SELECT [uuid] FROM collection WHERE [user] = ?;', (username, ))
return tuple(map(lambda x: x[0], self.cursor.fetchall())) return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_getDetailOwn(self, token, uuid): def collection_getDetailOwn(self, token, uuid):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid], [ccn_name], [ccn_lastChange] FROM collection WHERE [ccn_user] = ? AND [ccn_uuid] = ?;', (username, uuid)) cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ? AND [uuid] = ?;', (username, uuid))
return self.cursor.fetchone() return cursor.fetchone()
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_addOwn(self, token, newname): def collection_addOwn(self, token, newname):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
newuuid = utils.GenerateUUID() newuuid = utils.GenerateUUID()
lastupdate = utils.GenerateUUID() lastupdate = utils.GenerateUUID()
self.cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);', cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
(newuuid, newname, username, lastupdate)) (newuuid, newname, username, lastupdate))
return newuuid return newuuid
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_updateOwn(self, token, uuid, newname, lastChange): def collection_updateOwn(self, token, uuid, newname, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
lastupdate = utils.GenerateUUID() lastupdate = utils.GenerateUUID()
self.cursor.execute('UPDATE collection SET [ccn_name] = ?, [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', ( cursor.execute('UPDATE collection SET [name] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
newname, newname,
lastupdate, lastupdate,
uuid, uuid,
lastChange lastChange
)) ))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.') raise DbException('Fail to update due to no matched rows or too much rows.')
return lastupdate return lastupdate
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_deleteOwn(self, token, uuid, lastChange): def collection_deleteOwn(self, token, uuid, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
self.cursor.execute('DELETE FROM collection WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', ( cursor.execute('DELETE FROM collection WHERE [uuid] = ? AND [last_change] = ?;', (
uuid, uuid,
lastChange lastChange
)) ))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
return True return True
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_getSharing(self, token, uuid): def collection_getSharing(self, token, uuid):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
self.cursor.execute('SELECT [ccn_target] FROM share WHERE [ccn_uuid] = ?;', (uuid, )) cursor.execute('SELECT [target] FROM share WHERE [uuid] = ?;', (uuid, ))
return tuple(map(lambda x: x[0], self.cursor.fetchall())) return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_deleteSharing(self, token, uuid, target, lastChange): def collection_deleteSharing(self, token, uuid, target, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
lastupdate = utils.GenerateUUID() lastupdate = utils.GenerateUUID()
self.cursor.execute('UPDATE collection SET [ccn_lastChange] = ?, WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (lastupdate, uuid, lastChange)) cursor.execute('UPDATE collection SET [last_change] = ?, WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
self.cursor.execute('DELETE FROM share WHERE [ccn_uuid] = ? AND [ccn_target] = ?;', (uuid, target)) cursor.execute('DELETE FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
return lastupdate return lastupdate
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_addSharing(self, token, uuid, target, lastChange): def collection_addSharing(self, token, uuid, target, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
lastupdate = utils.GenerateUUID() lastupdate = utils.GenerateUUID()
self.cursor.execute('UPDATE collection SET [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (lastupdate, uuid, lastChange)) cursor.execute('UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
self.cursor.execute('SELECT * FROM share WHERE [ccn_uuid] = ? AND [ccn_target] = ?;', (uuid, target)) cursor.execute('SELECT * FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
if len(self.cursor.fetchall()) != 0: if len(cursor.fetchall()) != 0:
raise Exception('Fail to insert duplicated item.') raise DbException('Fail to insert duplicated item.')
self.cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target)) cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
return lastupdate return lastupdate
@SafeDatabaseOperation @SafeDatabaseOperation
def collection_getShared(self, token): def collection_getShared(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT collection.ccn_uuid, collection.ccn_name, collection.ccn_user \ cursor.execute('SELECT collection.uuid, collection.name, collection.user \
FROM share INNER JOIN collection \ FROM share INNER JOIN collection \
ON share.ccn_uuid = collection.ccn_uuid \ ON share.uuid = collection.uuid \
WHERE share.ccn_target = ?;', (username, )) WHERE share.target = ?;', (username, ))
return self.cursor.fetchall() return cursor.fetchall()
# =============================== todo # =============================== todo
@SafeDatabaseOperation @SafeDatabaseOperation
def todo_getFull(self, token): def todo_getFull(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT * FROM todo WHERE [ccn_belongTo] = ?;', (username, )) cursor.execute('SELECT * FROM todo WHERE [belong_to] = ?;', (username, ))
return self.cursor.fetchall() return cursor.fetchall()
@SafeDatabaseOperation @SafeDatabaseOperation
def todo_getList(self, token): def todo_getList(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid] FROM todo WHERE [ccn_belongTo] = ?;', (username, )) cursor.execute('SELECT [uuid] FROM todo WHERE [belong_to] = ?;', (username, ))
return tuple(map(lambda x: x[0], self.cursor.fetchall())) return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation @SafeDatabaseOperation
def todo_getDetail(self, token, uuid): def todo_getDetail(self, token, uuid):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT * FROM todo WHERE [ccn_belongTo] = ? AND [ccn_uuid] = ?;', (username, uuid)) cursor.execute('SELECT * FROM todo WHERE [belong_to] = ? AND [uuid] = ?;', (username, uuid))
return self.cursor.fetchone() return cursor.fetchone()
@SafeDatabaseOperation @SafeDatabaseOperation
def todo_add(self, token): def todo_add(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
newuuid = utils.GenerateUUID() newuuid = utils.GenerateUUID()
lastupdate = utils.GenerateUUID() lastupdate = utils.GenerateUUID()
@@ -472,56 +560,60 @@ class CalendarDatabase:
'', '',
lastupdate, lastupdate,
) )
self.cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData) cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
return returnedData return returnedData
@SafeDatabaseOperation @SafeDatabaseOperation
def todo_update(self, token, uuid, data, lastChange): def todo_update(self, token, uuid, data, lastChange):
cursor = self._get_cursor()
# check valid token # check valid token
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
# update # update
newLastChange = utils.GenerateUUID() newLastChange = utils.GenerateUUID()
self.cursor.execute('UPDATE todo SET [ccn_data] = ?, [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', ( cursor.execute('UPDATE todo SET [data] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
data, data,
newLastChange, newLastChange,
uuid, uuid,
lastChange lastChange
)) ))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.') raise DbException('Fail to update due to no matched rows or too much rows.')
return newLastChange return newLastChange
@SafeDatabaseOperation @SafeDatabaseOperation
def todo_delete(self, token, uuid, lastChange): def todo_delete(self, token, uuid, lastChange):
cursor = self._get_cursor()
# check valid token # check valid token
self.tokenOper_check_valid(token) self.tokenOper_check_valid(token)
# delete # delete
self.cursor.execute('DELETE FROM todo WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange)) cursor.execute('DELETE FROM todo WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
return True return True
# =============================== admin # =============================== admin
@SafeDatabaseOperation @SafeDatabaseOperation
def admin_get(self, token): def admin_get(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(username): if not self.tokenOper_is_admin(username):
raise Exception('Permission denied.') raise DbException('Permission denied.')
self.cursor.execute('SELECT [ccn_name], [ccn_isAdmin] FROM user;') cursor.execute('SELECT [name], [is_admin] FROM user;')
return tuple(map(lambda x: (x[0], x[1] == 1), self.cursor.fetchall())) return tuple(map(lambda x: (x[0], x[1] == 1), cursor.fetchall()))
@SafeDatabaseOperation @SafeDatabaseOperation
def admin_add(self, token, newname): def admin_add(self, token, newname):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(username): if not self.tokenOper_is_admin(username):
raise Exception('Permission denied.') raise DbException('Permission denied.')
newpassword = utils.ComputePasswordHash(utils.GenerateUUID()) newpassword = utils.ComputePasswordHash(utils.GenerateUUID())
self.cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', ( cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
newname, newname,
newpassword, newpassword,
0, 0,
@@ -531,9 +623,10 @@ class CalendarDatabase:
@SafeDatabaseOperation @SafeDatabaseOperation
def admin_update(self, token, _username, **optArgs): def admin_update(self, token, _username, **optArgs):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(username): if not self.tokenOper_is_admin(username):
raise Exception('Permission denied.') raise DbException('Permission denied.')
# construct data # construct data
sqlList = [] sqlList = []
@@ -542,45 +635,48 @@ class CalendarDatabase:
# analyse opt arg # analyse opt arg
cache = optArgs.get('password', None) cache = optArgs.get('password', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_password] = ?') sqlList.append('[password] = ?')
argumentsList.append(utils.ComputePasswordHash(cache)) argumentsList.append(utils.ComputePasswordHash(cache))
cache = optArgs.get('isAdmin', None) cache = optArgs.get('isAdmin', None)
if cache is not None: if cache is not None:
sqlList.append('[ccn_isAdmin] = ?') sqlList.append('[is_admin] = ?')
argumentsList.append(1 if cache else 0) argumentsList.append(1 if cache else 0)
# execute # execute
argumentsList.append(_username) argumentsList.append(_username)
self.cursor.execute('UPDATE user SET {} WHERE [ccn_name] = ?;'.format(', '.join(sqlList)), cursor.execute('UPDATE user SET {} WHERE [name] = ?;'.format(', '.join(sqlList)),
tuple(argumentsList)) tuple(argumentsList))
logging.debug(cache) LOGGER.debug(cache)
logging.debug(tuple(argumentsList)) LOGGER.debug(tuple(argumentsList))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.') raise DbException('Fail to update due to no matched rows or too much rows.')
return True return True
@SafeDatabaseOperation @SafeDatabaseOperation
def admin_delete(self, token, username): def admin_delete(self, token, username):
cursor = self._get_cursor()
_username = self.tokenOper_get_username(token) _username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(_username): if not self.tokenOper_is_admin(_username):
raise Exception('Permission denied.') raise DbException('Permission denied.')
# delete # delete
self.cursor.execute('DELETE FROM user WHERE [ccn_name] = ?;', (username, )) cursor.execute('DELETE FROM user WHERE [name] = ?;', (username, ))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
return True return True
# =============================== profile # =============================== profile
@SafeDatabaseOperation @SafeDatabaseOperation
def profile_isAdmin(self, token): def profile_isAdmin(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
return self.tokenOper_is_admin(username) return self.tokenOper_is_admin(username)
@SafeDatabaseOperation @SafeDatabaseOperation
def profile_changePassword(self, token, newpassword): def profile_changePassword(self, token, newpassword):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('UPDATE user SET [ccn_password] = ? WHERE [ccn_name] = ?;', ( cursor.execute('UPDATE user SET [password] = ? WHERE [name] = ?;', (
utils.ComputePasswordHash(newpassword), utils.ComputePasswordHash(newpassword),
username username
)) ))
@@ -588,23 +684,25 @@ class CalendarDatabase:
@SafeDatabaseOperation @SafeDatabaseOperation
def profile_getToken(self, token): def profile_getToken(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token) username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT * FROM token WHERE [ccn_user] = ?;', ( cursor.execute('SELECT * FROM token WHERE [user] = ?;', (
username, username,
)) ))
return self.cursor.fetchall() return cursor.fetchall()
@SafeDatabaseOperation @SafeDatabaseOperation
def profile_deleteToken(self, token, deleteToken): def profile_deleteToken(self, token, deleteToken):
cursor = self._get_cursor()
_username = self.tokenOper_get_username(token) _username = self.tokenOper_get_username(token)
# delete # delete
self.cursor.execute('DELETE FROM token WHERE [ccn_user] = ? AND [ccn_token] = ?;', ( cursor.execute('DELETE FROM token WHERE [user] = ? AND [token] = ?;', (
_username, _username,
deleteToken deleteToken
)) ))
if self.cursor.rowcount != 1: if cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.') raise DbException('Fail to delete due to no matched rows or too much rows.')
return True 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,457 +1,513 @@
from flask import Flask from flask import Flask
# from flask import g
from flask import render_template
from flask import url_for
from flask import request from flask import request
# from flask import abort from dataclasses import dataclass
from flask import redirect from typing import Any, Callable, ParamSpec, TypeVar, Generic
# from functools import reduce
# import json
# import os
import config import config
import database import database
import utils import utils
from pathlib import Path from logger import LOGGER
from database import ResponseBody
_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" app = Flask(__name__)
app = Flask(
__name__,
static_folder=_FRONTEND_PATH / "static",
template_folder=_FRONTEND_PATH / "templates",
)
calendar_db = database.CalendarDatabase() calendar_db = database.CalendarDatabase()
# render_static_resources = None # region: API Route
# =============================================database # region: Common
# def get_database():
# db = getattr(g, '_database', None)
# if db is None:
# db = database.CalendarDatabase()
# db.open()
# return db
# @app.teardown_appcontext
# def close_database(exception):
# db = getattr(g, '_database', None)
# if db is not None:
# db.close()
# ============================================= static page route @app.route("/common/salt", methods=["POST"])
@app.route('/', methods=['GET'])
def nospecHandle():
return redirect(url_for('web_homeHandle'))
@app.route('/web/home', methods=['GET'])
def web_homeHandle():
# UpdateStaticResources()
return render_template("home.html")
@app.route('/web/calendar', methods=['GET'])
def web_calendarHandle():
# UpdateStaticResources()
return render_template("calendar.html")
@app.route('/web/todo', methods=['GET'])
def web_todoHandle():
# UpdateStaticResources()
return render_template("todo.html")
@app.route('/web/admin', methods=['GET'])
def web_adminHandle():
# UpdateStaticResources()
return render_template("admin.html")
@app.route('/web/login', methods=['GET'])
def web_loginHandle():
# UpdateStaticResources()
return render_template("login.html")
@app.route('/web/collection', methods=['GET'])
def web_collectionHandle():
# UpdateStaticResources()
return render_template("collection.html")
@app.route('/web/eventAdd', methods=['GET'])
def web_eventAddHandle():
# UpdateStaticResources()
return render_template("event.html",
uuidPath=''
)
@app.route('/web/eventUpdate/<path:uuidPath>', methods=['GET'])
def web_eventUpdateHandle(uuidPath):
# UpdateStaticResources()
return render_template("event.html",
uuidPath = uuidPath
)
# ============================================= query page route
# ================================ common
@app.route('/api/common/salt', methods=['POST'])
def api_common_saltHandle(): def api_common_saltHandle():
return SmartDbCaller(calendar_db.common_salt, return SmartDbCaller(
(('username', str, False), ), calendar_db.common_salt, (FormField("username", str, False),), None
None) )
@app.route('/api/common/login', methods=['POST'])
@app.route("/common/login", methods=["POST"])
def api_common_loginHandle(): def api_common_loginHandle():
# construct client data first clientInfo = FetchClientNetworkInfo()
clientUa = request.user_agent.string
if request.headers.getlist("X-Forwarded-For"):
clientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
clientIp = request.remote_addr
return SmartDbCaller(calendar_db.common_login, return SmartDbCaller(
(('username', str, False), calendar_db.common_login,
('password', str, False), (
('clientUa', str, False), FormField("username", str, False),
('clientIp', str, False)), FormField("password", str, False),
{ FormField("clientUa", str, False),
'clientUa': clientUa, FormField("clientIp", str, False),
'clientIp': clientIp ),
}) {"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
)
@app.route('/api/common/webLogin', methods=['POST'])
@app.route("/common/webLogin", methods=["POST"])
def api_common_webLoginHandle(): def api_common_webLoginHandle():
# construct client data first clientInfo = FetchClientNetworkInfo()
clientUa = request.user_agent.string
if request.headers.getlist("X-Forwarded-For"):
clientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
clientIp = request.remote_addr
return SmartDbCaller(calendar_db.common_webLogin, return SmartDbCaller(
(('username', str, False), calendar_db.common_webLogin,
('password', str, False), (
('clientUa', str, False), FormField("username", str, False),
('clientIp', str, False)), FormField("password", str, False),
{ FormField("clientUa", str, False),
'clientUa': clientUa, FormField("clientIp", str, False),
'clientIp': clientIp ),
}) {"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
)
@app.route('/api/common/logout', methods=['POST'])
@app.route("/common/logout", methods=["POST"])
def api_common_logoutHandle(): def api_common_logoutHandle():
return SmartDbCaller(calendar_db.common_logout, return SmartDbCaller(
(('token', str, False), ), calendar_db.common_logout, (FormField("token", str, False),), None
None) )
@app.route('/api/common/tokenValid', methods=['POST'])
@app.route("/common/tokenValid", methods=["POST"])
def api_common_tokenValidHandle(): def api_common_tokenValidHandle():
return SmartDbCaller(calendar_db.common_tokenValid, return SmartDbCaller(
(('token', str, False), ), calendar_db.common_tokenValid, (FormField("token", str, False),), None
None) )
# ================================ calendar
@app.route('/api/calendar/getFull', methods=['POST']) # endregion
# region: Calendar
@app.route("/calendar/getFull", methods=["POST"])
def api_calendar_getFullHandle(): def api_calendar_getFullHandle():
return SmartDbCaller(calendar_db.calendar_getFull, return SmartDbCaller(
(('token', str, False), calendar_db.calendar_getFull,
('startDateTime', int, False), (
('endDateTime', int, False)), FormField("token", str, False),
None) FormField("startDateTime", int, False),
FormField("endDateTime", int, False),
),
None,
)
@app.route('/api/calendar/getList', methods=['POST'])
@app.route("/calendar/getList", methods=["POST"])
def api_calendar_getListHandle(): def api_calendar_getListHandle():
return SmartDbCaller(calendar_db.calendar_getList, return SmartDbCaller(
(('token', str, False), calendar_db.calendar_getList,
('startDateTime', int, False), (
('endDateTime', int, False)), FormField("token", str, False),
None) FormField("startDateTime", int, False),
FormField("endDateTime", int, False),
),
None,
)
@app.route('/api/calendar/getDetail', methods=['POST'])
@app.route("/calendar/getDetail", methods=["POST"])
def api_calendar_getDetailHandle(): def api_calendar_getDetailHandle():
return SmartDbCaller(calendar_db.calendar_getDetail, return SmartDbCaller(
(('token', str, False), calendar_db.calendar_getDetail,
('uuid', str, False)), (FormField("token", str, False), FormField("uuid", str, False)),
None) None,
)
@app.route('/api/calendar/update', methods=['POST'])
@app.route("/calendar/update", methods=["POST"])
def api_calendar_updateHandle(): def api_calendar_updateHandle():
return SmartDbCaller(calendar_db.calendar_update, return SmartDbCaller(
(('token', str, False), calendar_db.calendar_update,
('uuid', str, False), (
('belongTo', str, True), FormField("token", str, False),
('title', str, True), FormField("uuid", str, False),
('description', str, True), FormField("belongTo", str, True),
('eventDateTimeStart', int, True), FormField("title", str, True),
('eventDateTimeEnd', int, True), FormField("description", str, True),
('loopRules', str, True), FormField("eventDateTimeStart", int, True),
('timezoneOffset', int, True), FormField("eventDateTimeEnd", int, True),
('lastChange', str, False)), FormField("loopRules", str, True),
None) FormField("timezoneOffset", int, True),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/calendar/add', methods=['POST'])
@app.route("/calendar/add", methods=["POST"])
def api_calendar_addHandle(): def api_calendar_addHandle():
return SmartDbCaller(calendar_db.calendar_add, return SmartDbCaller(
(('token', str, False), calendar_db.calendar_add,
('belongTo', str, False), (
('title', str, False), FormField("token", str, False),
('description', str, False), FormField("belongTo", str, False),
('eventDateTimeStart', int, False), FormField("title", str, False),
('eventDateTimeEnd', int, False), FormField("description", str, False),
('loopRules', str, False), FormField("eventDateTimeStart", int, False),
('timezoneOffset', int, False)), FormField("eventDateTimeEnd", int, False),
None) FormField("loopRules", str, False),
FormField("timezoneOffset", int, False),
),
None,
)
@app.route('/api/calendar/delete', methods=['POST'])
@app.route("/calendar/delete", methods=["POST"])
def api_calendar_deleteHandle(): def api_calendar_deleteHandle():
return SmartDbCaller(calendar_db.calendar_delete, return SmartDbCaller(
(('token', str, False), calendar_db.calendar_delete,
('uuid', str, False), (
('lastChange', str, False)), FormField("token", str, False),
None) FormField("uuid", str, False),
FormField("lastChange", str, False),
),
None,
)
# ================================ collection
@app.route('/api/collection/getFullOwn', methods=['POST']) # endregion
# region: Collection
@app.route("/collection/getFullOwn", methods=["POST"])
def api_collection_getFullOwnHandle(): def api_collection_getFullOwnHandle():
return SmartDbCaller(calendar_db.collection_getFullOwn, return SmartDbCaller(
(('token', str, False), ), calendar_db.collection_getFullOwn, (FormField("token", str, False),), None
None) )
@app.route('/api/collection/getListOwn', methods=['POST'])
@app.route("/collection/getListOwn", methods=["POST"])
def api_collection_getListOwnHandle(): def api_collection_getListOwnHandle():
return SmartDbCaller(calendar_db.collection_getListOwn, return SmartDbCaller(
(('token', str, False), ), calendar_db.collection_getListOwn, (FormField("token", str, False),), None
None) )
@app.route('/api/collection/getDetailOwn', methods=['POST'])
@app.route("/collection/getDetailOwn", methods=["POST"])
def api_collection_getDetailOwnHandle(): def api_collection_getDetailOwnHandle():
return SmartDbCaller(calendar_db.collection_getDetailOwn, return SmartDbCaller(
(('token', str, False), calendar_db.collection_getDetailOwn,
('uuid', str, False)), (FormField("token", str, False), FormField("uuid", str, False)),
None) None,
)
@app.route('/api/collection/addOwn', methods=['POST'])
@app.route("/collection/addOwn", methods=["POST"])
def api_collection_addOwnHandle(): def api_collection_addOwnHandle():
return SmartDbCaller(calendar_db.collection_addOwn, return SmartDbCaller(
(('token', str, False), calendar_db.collection_addOwn,
('name', str, False)), (FormField("token", str, False), FormField("name", str, False)),
None) None,
)
@app.route('/api/collection/updateOwn', methods=['POST'])
@app.route("/collection/updateOwn", methods=["POST"])
def api_collection_updateOwnHandle(): def api_collection_updateOwnHandle():
return SmartDbCaller(calendar_db.collection_updateOwn, return SmartDbCaller(
(('token', str, False), calendar_db.collection_updateOwn,
('uuid', str, False), (
('name', str, False), FormField("token", str, False),
('lastChange', str, False)), FormField("uuid", str, False),
None) FormField("name", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/deleteOwn', methods=['POST'])
@app.route("/collection/deleteOwn", methods=["POST"])
def api_collection_deleteOwnHandle(): def api_collection_deleteOwnHandle():
return SmartDbCaller(calendar_db.collection_deleteOwn, return SmartDbCaller(
(('token', str, False), calendar_db.collection_deleteOwn,
('uuid', str, False), (
('lastChange', str, False)), FormField("token", str, False),
None) FormField("uuid", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/getSharing', methods=['POST']) @app.route("/collection/getSharing", methods=["POST"])
def api_collection_getSharingHandle(): def api_collection_getSharingHandle():
return SmartDbCaller(calendar_db.collection_getSharing, return SmartDbCaller(
(('token', str, False), calendar_db.collection_getSharing,
('uuid', str, False)), (FormField("token", str, False), FormField("uuid", str, False)),
None) None,
)
@app.route('/api/collection/deleteSharing', methods=['POST'])
@app.route("/collection/deleteSharing", methods=["POST"])
def api_collection_deleteSharingHandle(): def api_collection_deleteSharingHandle():
return SmartDbCaller(calendar_db.collection_deleteSharing, return SmartDbCaller(
(('token', str, False), calendar_db.collection_deleteSharing,
('uuid', str, False), (
('target', str, False), FormField("token", str, False),
('lastChange', str, False)), FormField("uuid", str, False),
None) FormField("target", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/addSharing', methods=['POST'])
@app.route("/collection/addSharing", methods=["POST"])
def api_collection_addSharingHandle(): def api_collection_addSharingHandle():
return SmartDbCaller(calendar_db.collection_addSharing, return SmartDbCaller(
(('token', str, False), calendar_db.collection_addSharing,
('uuid', str, False), (
('target', str, False), FormField("token", str, False),
('lastChange', str, False)), FormField("uuid", str, False),
None) FormField("target", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/getShared', methods=['POST']) @app.route("/collection/getShared", methods=["POST"])
def api_collection_getSharedHandle(): def api_collection_getSharedHandle():
return SmartDbCaller(calendar_db.collection_getShared, return SmartDbCaller(
(('token', str, False), ), calendar_db.collection_getShared, (FormField("token", str, False),), None
None) )
# ================================ todo
@app.route('/api/todo/getFull', methods=['POST']) # endregion
# region: Todo
@app.route("/todo/getFull", methods=["POST"])
def api_todo_getFullHandle(): def api_todo_getFullHandle():
return SmartDbCaller(calendar_db.todo_getFull, return SmartDbCaller(
(('token', str, False), ), calendar_db.todo_getFull, (FormField("token", str, False),), None
None) )
@app.route('/api/todo/getList', methods=['POST'])
@app.route("/todo/getList", methods=["POST"])
def api_todo_getListHandle(): def api_todo_getListHandle():
return SmartDbCaller(calendar_db.todo_getList, return SmartDbCaller(
(('token', str, False), ), calendar_db.todo_getList, (FormField("token", str, False),), None
None) )
@app.route('/api/todo/getDetail', methods=['POST'])
@app.route("/todo/getDetail", methods=["POST"])
def api_todo_getDetailHandle(): def api_todo_getDetailHandle():
return SmartDbCaller(calendar_db.todo_getDetail, return SmartDbCaller(
(('token', str, False), calendar_db.todo_getDetail,
('uuid', str, False)), (FormField("token", str, False), FormField("uuid", str, False)),
None) None,
)
@app.route('/api/todo/add', methods=['POST'])
@app.route("/todo/add", methods=["POST"])
def api_todo_addHandle(): def api_todo_addHandle():
return SmartDbCaller(calendar_db.todo_add, return SmartDbCaller(calendar_db.todo_add, (FormField("token", str, False),), None)
(('token', str, False), ),
None)
@app.route('/api/todo/update', methods=['POST'])
@app.route("/todo/update", methods=["POST"])
def api_todo_updateHandle(): def api_todo_updateHandle():
return SmartDbCaller(calendar_db.todo_update, return SmartDbCaller(
(('token', str, False), calendar_db.todo_update,
('uuid', str, False), (
('data', str, False), FormField("token", str, False),
('lastChange', str, False)), FormField("uuid", str, False),
None) FormField("data", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/todo/delete', methods=['POST'])
@app.route("/todo/delete", methods=["POST"])
def api_todo_deleteHandle(): def api_todo_deleteHandle():
return SmartDbCaller(calendar_db.todo_delete, return SmartDbCaller(
(('token', str, False), calendar_db.todo_delete,
('uuid', str, False), (
('lastChange', str, False)), FormField("token", str, False),
None) FormField("uuid", str, False),
FormField("lastChange", str, False),
),
None,
)
# ================================ admin
@app.route('/api/admin/get', methods=['POST']) # endregion
# region: Admin
@app.route("/admin/get", methods=["POST"])
def api_admin_getHandle(): def api_admin_getHandle():
return SmartDbCaller(calendar_db.admin_get, return SmartDbCaller(calendar_db.admin_get, (FormField("token", str, False),), None)
(('token', str, False), ),
None)
@app.route('/api/admin/add', methods=['POST'])
@app.route("/admin/add", methods=["POST"])
def api_admin_addHandle(): def api_admin_addHandle():
return SmartDbCaller(calendar_db.admin_add, return SmartDbCaller(
(('token', str, False), calendar_db.admin_add,
('username', str, False)), (FormField("token", str, False), FormField("username", str, False)),
None) None,
)
@app.route('/api/admin/update', methods=['POST'])
@app.route("/admin/update", methods=["POST"])
def api_admin_updateHandle(): def api_admin_updateHandle():
return SmartDbCaller(calendar_db.admin_update, return SmartDbCaller(
(('token', str, False), calendar_db.admin_update,
('username', str, False), (
('password', str, True), FormField("token", str, False),
('isAdmin', utils.Str2Bool, True)), FormField("username", str, False),
None) FormField("password", str, True),
FormField("isAdmin", utils.Str2Bool, True),
),
None,
)
@app.route('/api/admin/delete', methods=['POST'])
@app.route("/admin/delete", methods=["POST"])
def api_admin_deleteHandle(): def api_admin_deleteHandle():
return SmartDbCaller(calendar_db.admin_delete, return SmartDbCaller(
(('token', str, False), calendar_db.admin_delete,
('username', str, False)), (FormField("token", str, False), FormField("username", str, False)),
None) None,
)
# ================================ profile
@app.route('/api/profile/isAdmin', methods=['POST']) # endregion
# region: Profile
@app.route("/profile/isAdmin", methods=["POST"])
def api_profile_isAdminHandle(): def api_profile_isAdminHandle():
return SmartDbCaller(calendar_db.profile_isAdmin, return SmartDbCaller(
(('token', str, False), ), calendar_db.profile_isAdmin, (FormField("token", str, False),), None
None) )
@app.route('/api/profile/changePassword', methods=['POST'])
@app.route("/profile/changePassword", methods=["POST"])
def api_profile_changePasswordHandle(): def api_profile_changePasswordHandle():
return SmartDbCaller(calendar_db.profile_changePassword, return SmartDbCaller(
(('token', str, False), calendar_db.profile_changePassword,
('password', str, False)), (FormField("token", str, False), FormField("password", str, False)),
None) None,
)
@app.route('/api/profile/getToken', methods=['POST'])
@app.route("/profile/getToken", methods=["POST"])
def api_profile_getTokenHandle(): def api_profile_getTokenHandle():
return SmartDbCaller(calendar_db.profile_getToken, return SmartDbCaller(
(('token', str, False), ), calendar_db.profile_getToken, (FormField("token", str, False),), None
None) )
@app.route('/api/profile/deleteToken', methods=['POST'])
@app.route("/profile/deleteToken", methods=["POST"])
def api_profile_deleteTokenHandle(): def api_profile_deleteTokenHandle():
return SmartDbCaller(calendar_db.profile_deleteToken, return SmartDbCaller(
(('token', str, False), calendar_db.profile_deleteToken,
('deleteToken', str, False)), (FormField("token", str, False), FormField("deleteToken", str, False)),
None) None,
)
# =============================================main run
''' # endregion
def UpdateStaticResources():
global render_static_resources
if render_static_resources is not None:
return
render_static_resources = { # endregion
'url_js_localStorageAssist': url_for('static', filename='js/localStorageAssist.js'),
'url_js_i18n': url_for('static', filename='js/i18n.js'),
'url_js_api': url_for('static', filename='js/api.js'),
'url_js_headerNav': url_for('static', filename='js/headerNav.js'),
'url_tmpl_headerNac': url_for('static', filename='tmpl/headerNav.tmpl'), # region: Utilities
'url_js_pageHome': url_for('static', filename='js/page/home.js')
}
'''
def SmartDbCaller(dbMethod, paramTuple, extraDict): @dataclass(frozen=True)
result = (False, 'Invalid parameter', None) class ClientNetworkInfo:
optCount = 0 user_agent: str
paramList = [] """The user agent of client."""
optParamDict = {} ip_addr: str
# for each item, """The IP address of client."""
# item[0] is field name.
# item[1] is type.
# item[2] is whether it is optional field def FetchClientNetworkInfo() -> ClientNetworkInfo:
realForm = request.form.to_dict() clientUa = request.user_agent.string
if extraDict is not None: forwardIpList = request.headers.getlist("X-Forwarded-For")
realForm.update(extraDict) if forwardIpList:
for item in paramTuple: clientIp = forwardIpList[0]
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: else:
# at least one opt param directIp = request.remote_addr
if optCount == 0 or len(optParamDict) != 0: if directIp is not None:
result = dbMethod(*paramList, **optParamDict) clientIp = directIp
else:
clientIp = "0.0.0.0"
return ClientNetworkInfo(clientUa, clientIp)
@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[..., ResponseBody[Any]],
fields: tuple[FormField, ...],
padding_form: dict[str, str] | None,
) -> dict[str, Any]:
opt_param_counter = 0
lost_required: bool = False
param_list: list[Any] = []
opt_param_dict: dict[str, Any] = {}
# fetch user passed form
user_form: dict[str, str] = request.form.to_dict()
LOGGER.debug(f"User Form: {user_form}")
# overwrite user form by our padding form
if padding_form is not None:
user_form.update(padding_form)
LOGGER.debug(f"Padded User Form: {user_form}")
# check fields one by one
for field in fields:
value = user_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:
lost_required = True
else:
param_list.append(value)
# Only execute database function if there is no lost required fields.
# And fulfill one of following requirements:
# 1. There are all required fields (optional parameter count is zero).
# 1. Or, there is some optional parameter.
LOGGER.debug(f"Has Lost Required Parameter: {lost_required}")
LOGGER.debug(f"All Optional Parameter Count: {opt_param_counter}")
LOGGER.debug(f"Available Optional Parameter Count: {len(opt_param_dict)}")
result: ResponseBody[Any]
if lost_required == False and (opt_param_counter == 0 or len(opt_param_dict) != 0):
result = db_method(*param_list, **opt_param_dict)
else:
result = ResponseBody(False, "Invalid parameter", None)
return ConstructResponseBody(result) return ConstructResponseBody(result)
def ConstructResponseBody(returnedTuple):
return { def ConstructResponseBody(body: ResponseBody[Any]) -> dict[str, Any]:
'success': returnedTuple[0], return {"success": body.success, "error": body.error, "data": body.data}
'error': returnedTuple[1],
'data': returnedTuple[2]
} # endregion
def run(): def run():
calendar_db.open() calendar_db.open()
app.run(port=config.get_config().web.port) app.run(port=config.get_config().web.port)
calendar_db.close() calendar_db.close()

View File

@@ -1,67 +1,67 @@
CREATE TABLE user( CREATE TABLE user(
[ccn_name] TEXT NOT NULL, [name] TEXT NOT NULL,
[ccn_password] TEXT NOT NULL, [password] TEXT NOT NULL,
[ccn_isAdmin] TINYINT NOT NULL CHECK(ccn_isAdmin = 1 OR ccn_isAdmin = 0), [is_admin] TINYINT NOT NULL CHECK(is_admin = 1 OR is_admin = 0),
[ccn_salt] INTEGER NOT NULL, [salt] INTEGER NOT NULL,
PRIMARY KEY (ccn_name) PRIMARY KEY (name)
); );
CREATE TABLE token( CREATE TABLE token(
[ccn_user] TEXT NOT NULL, [user] TEXT NOT NULL,
[ccn_token] TEXT UNIQUE NOT NULL, [token] TEXT UNIQUE NOT NULL,
[ccn_tokenExpireOn] BIGINT NOT NULL, [token_expire_on] BIGINT NOT NULL,
[ccn_ua] TEXT NOT NULL, [ua] TEXT NOT NULL,
[ccn_ip] TEXT NOT NULL, [ip] TEXT NOT NULL,
FOREIGN KEY (ccn_user) REFERENCES user(ccn_name) ON DELETE CASCADE FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
); );
CREATE TABLE collection( CREATE TABLE collection(
[ccn_uuid] TEXT NOT NULL, [uuid] TEXT NOT NULL,
[ccn_name] TEXT NOT NULL, [name] TEXT NOT NULL,
[ccn_user] TEXT NOT NULL, [user] TEXT NOT NULL,
[ccn_lastChange] TEXT NOT NULL, [last_change] TEXT NOT NULL,
PRIMARY KEY (ccn_uuid), PRIMARY KEY (uuid),
FOREIGN KEY (ccn_user) REFERENCES user(ccn_name) ON DELETE CASCADE FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
); );
CREATE TABLE share( CREATE TABLE share(
[ccn_uuid] TEXT NOT NULL, [uuid] TEXT NOT NULL,
[ccn_target] TEXT NOT NULL, [target] TEXT NOT NULL,
FOREIGN KEY (ccn_uuid) REFERENCES collection(ccn_uuid) ON DELETE CASCADE FOREIGN KEY (uuid) REFERENCES collection(uuid) ON DELETE CASCADE
FOREIGN KEY (ccn_target) REFERENCES user(ccn_name) ON DELETE CASCADE FOREIGN KEY (target) REFERENCES user(name) ON DELETE CASCADE
); );
CREATE TABLE calendar( CREATE TABLE calendar(
[ccn_uuid] TEXT NOT NULL, [uuid] TEXT NOT NULL,
[ccn_belongTo] TEXT NOT NULL, [belong_to] TEXT NOT NULL,
[ccn_title] TEXT NOT NULL, [title] TEXT NOT NULL,
[ccn_description] TEXT NOT NULL, [description] TEXT NOT NULL,
[ccn_lastChange] TEXT NOT NULL, [last_change] TEXT NOT NULL,
[ccn_eventDateTimeStart] BIGINT NOT NULL, [event_date_time_start] BIGINT NOT NULL,
[ccn_eventDateTimeEnd] BIGINT NOT NULL, [event_date_time_end] BIGINT NOT NULL,
[ccn_timezoneOffset] INT NOT NULL, [timezone_offset] INT NOT NULL,
[ccn_loopRules] TEXT NOT NULL, [loop_rules] TEXT NOT NULL,
[ccn_loopDateTimeStart] BIGINT NOT NULL, [loop_date_time_start] BIGINT NOT NULL,
[ccn_loopDateTimeEnd] BIGINT NOT NULL, [loop_date_time_end] BIGINT NOT NULL,
PRIMARY KEY (ccn_uuid), PRIMARY KEY (uuid),
FOREIGN KEY (ccn_belongTo) REFERENCES collection(ccn_uuid) ON DELETE CASCADE FOREIGN KEY (belong_to) REFERENCES collection(uuid) ON DELETE CASCADE
); );
CREATE TABLE todo( CREATE TABLE todo(
[ccn_uuid] TEXT NOT NULL, [uuid] TEXT NOT NULL,
[ccn_belongTo] TEXT NOT NULL, [belong_to] TEXT NOT NULL,
[ccn_data] TEXT NOT NULL, [data] TEXT NOT NULL,
[ccn_lastChange] TEXT NOT NULL, [last_change] TEXT NOT NULL,
PRIMARY KEY (ccn_uuid), PRIMARY KEY (uuid),
FOREIGN KEY (ccn_belongTo) REFERENCES user(ccn_name) ON DELETE CASCADE FOREIGN KEY (belong_to) REFERENCES user(name) ON DELETE CASCADE
); );

View File

@@ -3,50 +3,62 @@ import random
import uuid import uuid
import time import time
import math import math
import re
ValidUsername = set(map(lambda x:chr(x), range(48, 58, 1))) | set(map(lambda x:chr(x), range(65, 91, 1))) | set(map(lambda x:chr(x), range(97, 123, 1))) USERNAME_PATTERN: re.Pattern = re.compile("^[0-9A-Za-z]+$")
ValidPassword = set(map(lambda x:chr(x), range(33, 127, 1))) PASSWORD_PATTERN: re.Pattern = re.compile("^[!-~]+$")
def IsValidUsername(strl):
return (len(set(strl) - ValidUsername) == 0)
def IsValidPassword(strl): def IsValidUsername(strl: str) -> bool:
return (len(set(strl) - ValidPassword) == 0) return USERNAME_PATTERN.match(strl) is not None
def ComputePasswordHash(password):
def IsValidPassword(strl: str) -> bool:
return PASSWORD_PATTERN.match(strl) is not None
def ComputePasswordHash(password: str) -> str:
s = hashlib.sha256() s = hashlib.sha256()
s.update(password.encode('utf-8')) s.update(password.encode("utf-8"))
return s.hexdigest() return s.hexdigest()
def GenerateUUID():
def GenerateUUID() -> str:
return str(uuid.uuid1()) return str(uuid.uuid1())
def GenerateToken(username):
def GenerateToken(username: str) -> str:
s = hashlib.sha256() s = hashlib.sha256()
s.update(username.encode('utf-8')) s.update(username.encode("utf-8"))
s.update(GenerateUUID().encode('utf-8')) s.update(GenerateUUID().encode("utf-8"))
return s.hexdigest() return s.hexdigest()
def GenerateSalt():
def GenerateSalt() -> int:
return random.randint(0, 6172748) return random.randint(0, 6172748)
def ComputePasswordHashWithSalt(passwordHashed, salt):
def ComputePasswordHashWithSalt(passwordHashed: str, salt: int) -> str:
s = hashlib.sha256() s = hashlib.sha256()
s.update((passwordHashed + str(salt)).encode('utf-8')) s.update((passwordHashed + str(salt)).encode("utf-8"))
return s.hexdigest() return s.hexdigest()
def GetCurrentTimestamp():
def GetCurrentTimestamp() -> int:
return int(time.time()) return int(time.time())
def GetTokenExpireOn():
return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
def Str2Bool(strl): def GetTokenExpireOn() -> int:
return strl.lower() == 'true' return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
def GCD(a, b):
def Str2Bool(strl: str) -> bool:
return strl.lower() == "true"
def GCD(a: int, b: int) -> int:
return math.gcd(a, b) return math.gcd(a, b)
def LCM(a, b):
return int(a * b / GCD(a, b)) def LCM(a: int, b: int) -> int:
return (a * b) // GCD(a, b)

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

8
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

10
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

48
frontend/README.md Normal file
View File

@@ -0,0 +1,48 @@
# coleaf-frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```

1
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

23
frontend/eslint.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
)

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>coconut-leaf</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

47
frontend/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "coleaf-frontend",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.2.0",
"axios": "1.14.0",
"bulma": "0.9.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.1",
"eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

3584
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,3 @@
// 导入 Bulma
@charset "utf-8";
@import "bulma/bulma.sass";

92
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useLanguageStore } from './stores/language';
import { useTokenStore } from './stores/token';
import MessageBox from '@/components/MessageBox.vue';
import { logout as apiCommonLogout } from './api/common';
import { goToHome } from '@/router';
const language = useLanguageStore();
const token = useTokenStore();
const isBurgerActive = ref<boolean>(false);
const messagebox = ref<InstanceType<typeof MessageBox> | null>(null);
const logout = async () => {
const tokenStore = useTokenStore();
const rv = await apiCommonLogout(tokenStore.currentToken);
if (rv) {
// OK. We logged out.
// Clear token.
tokenStore.logout();
// And go to Home page
goToHome();
} else {
// Show logout error.
messagebox.value?.show("Fail to logout due to unknow reason. Consider refreshing page to solve problem.");
}
}
// Process burger menu.
// This is copied from Bulma website and modified for Vue.
// Check for click events on the navbar burger icon
const toggleBurger = () => {
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
isBurgerActive.value = !isBurgerActive
}
</script>
<template>
<nav class="navbar has-shadow is-spaced bd-navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<router-link class="navbar-item" to="/">
<img src="/public/favicon.ico"><b style="margin:0 0 0 14px;">coconut-leaf</b>
</router-link>
<a role="button" class="navbar-burger burger" :class="{ 'is-active': isBurgerActive }" @click="toggleBurger"
aria-label="menu" aria-expanded="false" data-target="coleaf-navbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="coleaf-navbar" class="navbar-menu" :class="{ 'is-active': isBurgerActive }">
<div class="navbar-start">
<router-link class="navbar-item" to="/home">Home</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/collection">Collection</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/calendar">Calendar</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/todo">Todo</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/admin">Admin</router-link>
</div>
<div class="navbar-end">
<p class="navbar-item">
<router-link v-if="!token.isLoggedIn" class="button is-primary" to="/login">Login</router-link>
</p>
<p class="navbar-item">
<a v-if="token.isLoggedIn" class="button is-primary" @click="logout">Logout</a>
</p>
<div class="navbar-item has-dropdown is-hoverable">
<a v-if="language.isEnglish" class="navbar-link">English</a>
<a v-else-if="language.isSimplifiedChinese" class="navbar-link">简体中文</a>
<div class="navbar-dropdown">
<a @click="language.changeToEnglish()" class="navbar-item">English</a>
<a @click="language.changeToSimplifiedChinese()" class="navbar-item">简体中文</a>
</div>
</div>
</div>
</div>
</nav>
<!-- The output result of router -->
<router-view></router-view>
<MessageBox ref="messagebox" />
</template>
<style scoped></style>

75
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,75 @@
import { apiWrapper, boolApiWrapper } from './index'
// User interface
interface User {
username: string
isAdmin: boolean
// Add other user-related fields as needed
}
/**
* Get all users (admin only)
* @param token - Authentication token
* @returns Array of users or null if operation failed
*/
export async function get(token: string): Promise<User[] | undefined> {
return apiWrapper('/api/admin/get', { token });
}
/**
* Add new user (admin only)
* @param token - Authentication token
* @param username - Username
* @returns Created user or null if operation failed
*/
export async function add(token: string, username: string): Promise<User | undefined> {
return apiWrapper('/api/admin/add', { token, username });
}
/**
* Update user (admin only)
* @param token - Authentication token
* @param username - Username
* @param params - Update parameters (partial)
* @returns true if update successful, false otherwise
*/
export async function update(
token: string,
username: string,
params: {
password?: string
isAdmin?: boolean
}
): Promise<boolean> {
const data: any = {
token,
username
};
let count = 0;
if (typeof params.password !== 'undefined') {
data.password = params.password;
count++;
}
if (typeof params.isAdmin !== 'undefined') {
data.isAdmin = params.isAdmin;
count++;
}
// If no update parameters provided, return true
if (count === 0) {
return true;
}
return boolApiWrapper('/api/admin/update', data);
}
/**
* Delete user (admin only)
* @param token - Authentication token
* @param username - Username
* @returns true if deletion successful, false otherwise
*/
export async function deleteItem(token: string, username: string): Promise<boolean> {
return boolApiWrapper('/api/admin/delete', { token, username });
}

View File

@@ -0,0 +1,141 @@
import { apiWrapper, boolApiWrapper } from './index'
// Calendar event interface
interface CalendarEvent {
uuid: string
belongTo: string
title: string
description: string
eventDateTimeStart: string
eventDateTimeEnd: string
loopRules: string
timezoneOffset: number
lastChange: string
}
// Description object interface
interface DescriptionObject {
description: string
color: string
}
/**
* Serialize calendar description
* @param description - Description text
* @param color - Color value
* @returns JSON string
*/
export function serializeDescription(description: string, color: string): string {
const sobj: DescriptionObject = {
description,
color
}
return JSON.stringify(sobj)
}
/**
* Deserialize calendar description
* @param str - JSON string
* @returns Description object
*/
export function deserializeDescription(str: string): DescriptionObject {
try {
return JSON.parse(str) as DescriptionObject
} catch (err) {
return {
description: "",
color: "#000000" // DefaultColor
}
}
}
/**
* Get full calendar events within date range
* @param token - Authentication token
* @param startDateTime - Start datetime
* @param endDateTime - End datetime
* @returns Array of calendar events or null if operation failed
*/
export async function getFull(token: string, startDateTime: string, endDateTime: string): Promise<CalendarEvent[] | undefined> {
return apiWrapper('/api/calendar/getFull', { token, startDateTime, endDateTime });
}
/**
* Get calendar event detail by UUID
* @param token - Authentication token
* @param uuid - Event UUID
* @returns Calendar event or null if operation failed
*/
export async function getDetail(token: string, uuid: string): Promise<CalendarEvent | undefined> {
return apiWrapper('/api/calendar/getDetail', { token, uuid });
}
/**
* Update calendar event
* @param token - Authentication token
* @param uuid - Event UUID
* @param params - Update parameters (partial)
* @returns Updated calendar event or null if operation failed
*/
export async function update(
token: string,
uuid: string,
params: {
belongTo?: string
title?: string
description?: string
eventDateTimeStart?: string
eventDateTimeEnd?: string
loopRules?: string
timezoneOffset?: number
lastChange: string
}
): Promise<CalendarEvent | undefined> {
const data: any = {
token,
uuid,
lastChange: params.lastChange
};
if (params.belongTo !== undefined) data.belongTo = params.belongTo;
if (params.title !== undefined) data.title = params.title;
if (params.description !== undefined) data.description = params.description;
if (params.eventDateTimeStart !== undefined) data.eventDateTimeStart = params.eventDateTimeStart;
if (params.eventDateTimeEnd !== undefined) data.eventDateTimeEnd = params.eventDateTimeEnd;
if (params.loopRules !== undefined) data.loopRules = params.loopRules;
if (params.timezoneOffset !== undefined) data.timezoneOffset = params.timezoneOffset;
return apiWrapper('/api/calendar/update', data);
}
/**
* Add new calendar event
* @param token - Authentication token
* @param params - Event parameters
* @returns Created calendar event or null if operation failed
*/
export async function add(
token: string,
params: {
belongTo: string
title: string
description: string
eventDateTimeStart: string
eventDateTimeEnd: string
loopRules: string
timezoneOffset: number
}
): Promise<CalendarEvent | undefined> {
return apiWrapper('/api/calendar/add', { token, ...params });
}
/**
* Delete calendar event
* @param token - Authentication token
* @param uuid - Event UUID
* @param lastChange - Last change timestamp
* @returns true if deletion successful, false otherwise
*/
export async function deleteEvent(token: string, uuid: string, lastChange: string): Promise<boolean> {
return boolApiWrapper('/api/calendar/delete', { token, uuid, lastChange });
}

View File

@@ -0,0 +1,126 @@
import { apiWrapper, boolApiWrapper } from './index'
type Uuid = string;
// Collection item interface
interface CollectionItem {
uuid: Uuid
name: string
lastChange: string
}
// Sharing info interface
interface SharingInfo {
target: string
// Add other sharing-related fields as needed
}
/**
* Get all owned collections
* @param token - Authentication token
* @returns Array of collection items or null if operation failed
*/
export async function getFullOwn(token: string): Promise<CollectionItem[] | undefined> {
return apiWrapper('/api/collection/getFullOwn', { token });
}
/**
* Get owned collection detail by UUID
* @param token - Authentication token
* @param uuid - Collection UUID
* @returns Collection item or null if operation failed
*/
export async function getDetailOwn(token: string, uuid: Uuid): Promise<CollectionItem | undefined> {
return apiWrapper('/api/collection/getDetailOwn', { token, uuid });
}
/**
* Add new owned collection
* @param token - Authentication token
* @param name - Collection name
* @returns Created collection item or null if operation failed
*/
export async function addOwn(token: string, name: string): Promise<Uuid | undefined> {
return apiWrapper('/api/collection/addOwn', { token, name });
}
/**
* Update owned collection
* @param token - Authentication token
* @param uuid - Collection UUID
* @param name - New name
* @param lastChange - Last change timestamp
* @returns Updated collection item or null if operation failed
*/
export async function updateOwn(
token: string,
uuid: Uuid,
name: string,
lastChange: string
): Promise<CollectionItem | undefined> {
return apiWrapper('/api/collection/updateOwn', { token, uuid, name, lastChange });
}
/**
* Delete owned collection
* @param token - Authentication token
* @param uuid - Collection UUID
* @param lastChange - Last change timestamp
* @returns true if deletion successful, false otherwise
*/
export async function deleteOwn(token: string, uuid: Uuid, lastChange: string): Promise<boolean> {
return boolApiWrapper('/api/collection/deleteOwn', { token, uuid, lastChange });
}
/**
* Get sharing information for a collection
* @param token - Authentication token
* @param uuid - Collection UUID
* @returns Array of sharing info or null if operation failed
*/
export async function getSharing(token: string, uuid: Uuid): Promise<SharingInfo[] | undefined> {
return apiWrapper('/api/collection/getSharing', { token, uuid });
}
/**
* Delete sharing for a collection
* @param token - Authentication token
* @param uuid - Collection UUID
* @param target - Target user
* @param lastChange - Last change timestamp
* @returns Result data or null if operation failed
*/
export async function deleteSharing(
token: string,
uuid: Uuid,
target: string,
lastChange: string
): Promise<any | undefined> {
return apiWrapper('/api/collection/deleteSharing', { token, uuid, target, lastChange });
}
/**
* Add sharing for a collection
* @param token - Authentication token
* @param uuid - Collection UUID
* @param target - Target user
* @param lastChange - Last change timestamp
* @returns Result data or null if operation failed
*/
export async function addSharing(
token: string,
uuid: Uuid,
target: string,
lastChange: string
): Promise<any | undefined> {
return apiWrapper('/api/collection/addSharing', { token, uuid, target, lastChange });
}
/**
* Get all shared collections
* @param token - Authentication token
* @returns Array of collection items or null if operation failed
*/
export async function getShared(token: string): Promise<CollectionItem[] | undefined> {
return apiWrapper('/api/collection/getShared', { token });
}

View File

@@ -0,0 +1,43 @@
import { apiWrapper, boolApiWrapper } from './index'
// /**
// * Login with salt
// * @param username - Username
// * @param password - Password
// * @returns Token if login successful, undefined otherwise
// */
// export async function login(username: string, password: string): Promise<string | undefined> {
// const salt: string | undefined = await apiWrapper('/api/common/salt', { username });
// if (typeof salt === 'undefined') return undefined;
// const computedPassword = computePasswordWithSalt(password, salt);
// return apiWrapper('/api/common/login', { username, password: computedPassword });
// }
/**
* Web login
* @param username - Username
* @param password - Password
* @returns Token if login successful, undefined otherwise
*/
export async function webLogin(username: string, password: string): Promise<string | undefined> {
return apiWrapper('/api/common/webLogin', { username, password });
}
/**
* Logout
* @param token - Authentication token
* @returns true if logout successful, false otherwise if logout failed
*/
export async function logout(token: string): Promise<boolean> {
return boolApiWrapper('/api/common/logout', { token });
}
/**
* Validate token
* @param token - Authentication token
* @returns true if token is valid, false otherwise
*/
export async function tokenValid(token: string): Promise<boolean> {
return boolApiWrapper('/api/common/tokenValid', { token });
}

57
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,57 @@
// Response interface
interface ApiResponse<T = any> {
success: boolean,
error: string,
data: T,
}
export async function apiWrapper<T>(url: string, data: Record<string, any>): Promise<T | undefined> {
try {
// 自动编码为 key=value&key2=value2 格式
const params = new URLSearchParams();
Object.entries(data).forEach(([key, value]) => {
params.append(key, String(value));
});
// 发起请求
const response = await fetch(url, {
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "same-origin",
headers: {
// 明确指定内容类型
'Content-Type': 'application/x-www-form-urlencoded',
},
redirect: "follow",
referrerPolicy: "no-referrer",
body: params.toString(),
});
// 检查 HTTP 状态码 (fetch 只有在网络故障时才会 rejectHTTP 404/500 不会)
if (!response.ok) {
console.error(`HTTP failed: ${response.status}`);
}
// 解析 JSON body
// 注意response.json() 返回的是一个 Promise所以需要 await
const payload = await response.json() as ApiResponse<T>;
// 检查API返回结果
if (payload.success) {
return payload.data;
} else {
console.error(`API failed: ${payload.error}`);
return undefined;
}
} catch (error) {
// 统一错误处理
console.error(`Fetch failed: ${error}`);
return undefined;
}
}
export async function boolApiWrapper<U>(url: string, data: Record<string, any>): Promise<boolean> {
const rv = await apiWrapper<null>(url, data);
return rv !== undefined;
}

View File

@@ -0,0 +1,45 @@
import { apiWrapper, boolApiWrapper } from './index'
// Token info interface
interface TokenInfo {
token: string
// Add other token-related fields as needed
}
/**
* Check if current user is admin
* @param token - Authentication token
* @returns true if user is admin, false otherwise
*/
export async function isAdmin(token: string): Promise<boolean> {
return boolApiWrapper('/api/profile/isAdmin', { token });
}
/**
* Change user password
* @param token - Authentication token
* @param password - New password
* @returns true if change successful, false otherwise
*/
export async function changePassword(token: string, password: string): Promise<boolean> {
return boolApiWrapper('/api/profile/changePassword', { token, password });
}
/**
* Get user tokens
* @param token - Authentication token
* @returns Array of token info or undefined if operation failed
*/
export async function getToken(token: string): Promise<TokenInfo[] | undefined> {
return apiWrapper('/api/profile/getToken', { token });
}
/**
* Delete a token
* @param token - Authentication token
* @param deleteToken - Token to delete
* @returns true if deletion successful, false otherwise
*/
export async function deleteToken(token: string, deleteToken: string): Promise<boolean> {
return boolApiWrapper('/api/profile/deleteToken', { token, deleteToken });
}

56
frontend/src/api/todo.ts Normal file
View File

@@ -0,0 +1,56 @@
import axios from 'axios'
import { apiWrapper, boolApiWrapper } from './index'
// Todo item interface
interface TodoItem {
uuid: string
data: any
lastChange: string
// Add other todo-related fields as needed
}
/**
* Get all todos
* @param token - Authentication token
* @returns Array of todo items or null if operation failed
*/
export async function getFull(token: string): Promise<TodoItem[] | undefined> {
return apiWrapper('/api/todo/getFull', { token });
}
/**
* Add new todo
* @param token - Authentication token
* @returns Created todo item or null if operation failed
*/
export async function add(token: string): Promise<TodoItem | undefined> {
return apiWrapper('/api/todo/add', { token });
}
/**
* Update todo
* @param token - Authentication token
* @param uuid - Todo UUID
* @param data - Todo data
* @param lastChange - Last change timestamp
* @returns Updated todo item or null if operation failed
*/
export async function update(
token: string,
uuid: string,
data: any,
lastChange: string
): Promise<TodoItem | undefined> {
return apiWrapper('/api/todo/update', { token, uuid, data, lastChange });
}
/**
* Delete todo
* @param token - Authentication token
* @param uuid - Todo UUID
* @param lastChange - Last change timestamp
* @returns true if deletion successful, false otherwise
*/
export async function deleteTodo(token: string, uuid: string, lastChange: string): Promise<boolean> {
return boolApiWrapper('/api/todo/delete', { token, uuid, lastChange });
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue';
const isVisible = ref(false);
const title = ref<string>("");
const content = ref<string>("");
const show = (_content: string, _title?: string) => {
title.value = _title ?? "Notification";
content.value = _content;
isVisible.value = true;
}
const hide = () => {
isVisible.value = false;
}
defineExpose({
show
})
</script>
<template>
<div class="modal" :class="{ 'is-active': isVisible }" style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ title }}</p>
<button class="delete" aria-label="close" @click="hide"></button>
</header>
<div class="modal-card-body">
<p>{{ content }}</p>
</div>
<footer class="modal-card-foot">
<button class="button is-success" @click="hide">OK</button>
</footer>
</div>
</div>
</template>
<style scoped></style>

25
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUser, faLock } from '@fortawesome/free-solid-svg-icons'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import '../public/index.scss'
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
const app = createApp(App);
app.use(pinia);
app.use(router);
library.add(faUser, faLock);
app.component('font-awesome-icon', FontAwesomeIcon);
app.mount('#app');

View File

@@ -0,0 +1,55 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useTokenStore } from '@/stores/token'
import Home from '@/views/Home.vue'
import Collection from '@/views/Collection.vue'
import Calendar from '@/views/Calendar.vue'
import CalendarEvent from '@/views/CalendarEvent.vue'
import Todo from '@/views/Todo.vue'
import Admin from '@/views/Admin.vue'
import Login from '@/views/Login.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{ path: '/home', name: "Home", component: Home },
{ path: '/collection', name: "Collection", meta: { requireLoggedInCheck: true }, component: Collection },
{ path: '/calendar', name: "Calendar", meta: { requireLoggedInCheck: true }, component: Calendar },
{ path: '/todo', name: "Todo", meta: { requireLoggedInCheck: true }, component: Todo },
{ path: '/admin', name: "Admin", meta: { requireLoggedInCheck: true }, component: Admin },
{ path: '/calendar/event', name: "CalendarEvent", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
{ path: '/login', name: "Collection", meta: { requireLoggedOutCheck: true }, component: Login },
{ path: '/404', name: "NotFound", component: NotFound },
{ path: '/', name: "Default", redirect: '/home' },
{ path: '/:pathMatch(.*)*', redirect: '/404' },
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes,
});
router.beforeEach((to, from) => {
// Only check for those flagged.
const token = useTokenStore();
if (to.meta.requireLoggedInCheck) {
if (!token.isLoggedIn) {
return { name: 'Default', replace: true };
}
} else if (to.meta.requireLoggedOutCheck) {
if (token.isLoggedIn) {
return { name: 'Default', replace: true };
}
} else {
return true;
}
})
export const goToHome = () => {
router.push({ name: 'Home' })
}
export default router

View File

@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { Language } from '@/utils/i18n'
interface LanguageState {
language: Language
}
export const useLanguageStore = defineStore('language', {
state: (): LanguageState => ({
language: Language.English
}),
getters: {
isEnglish: (state) => state.language === Language.English,
isSimplifiedChinese: (state) => state.language === Language.SimplifiedChinese,
},
actions: {
changeLanguage(lang: Language) {
this.language = lang;
},
changeToEnglish() {
this.changeLanguage(Language.English);
},
changeToSimplifiedChinese() {
this.changeLanguage(Language.SimplifiedChinese);
},
},
persist: {
key: 'ccn-i18n',
storage: localStorage,
pick: ['language'],
},
})

View File

@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
interface TokenState {
token: string | null
}
export const useTokenStore = defineStore('token', {
state: (): TokenState => ({
token: null,
}),
getters: {
isLoggedIn: (state) => typeof state.token === 'string',
currentToken: (state) => state.token as string,
},
actions: {
login(token: string) {
this.token = token;
},
logout() {
this.token = null;
},
},
persist: {
key: 'ccn-token',
storage: localStorage,
pick: ['token'],
},
})

View File

@@ -0,0 +1,4 @@
export enum Language {
English,
SimplifiedChinese,
}

View File

@@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<h1>Congratulations</h1>
<p>This is admin.</p>
</template>
<style scoped></style>

View File

@@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<h1>Congratulations</h1>
<p>This is calendar.</p>
</template>
<style scoped></style>

View File

@@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<h1>Congratulations</h1>
<p>This is calendar event.</p>
</template>
<style scoped></style>

View File

@@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<h1>Congratulations</h1>
<p>This is collection.</p>
</template>
<style scoped></style>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts"></script>
<template>
<div class="container" style="margin-top: 1.25rem;">
<article>
<h1 class="title">coconut-leaf</h1>
<p>A light, self-host and multi-account calendar system.</p>
<p>The original intention of this system is served for yyc12345 personal use.</p>
<br />
<p>See our <a href="https://github.com/yyc12345/coconut-leaf">GitHub project</a> for the source code in detail.</p>
<p>The source code of this project is licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL v3</a>.</p>
</article>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useTokenStore } from '@/stores/token';
import MessageBox from '@/components/MessageBox.vue';
import { webLogin as apiCommonWebLogin } from '@/api/common';
import { goToHome } from '@/router';
const isLoggingIn = ref<boolean>(false);
const username = ref<string>("");
const password = ref<string>("");
const messagebox = ref<InstanceType<typeof MessageBox> | null>(null);
const login = async () => {
// disable UI first
isLoggingIn.value = true;
// // try get salt
// if (ccn_api_common_salt(username)) {
// // continue login
// if (ccn_api_common_login(username, password)) {
// // ok, logged
// // jump into home page again
// window.location.href = '/web/home';
// } else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
// } else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
const token = await apiCommonWebLogin(username.value, password.value);
if (typeof token !== 'undefined') {
// OK. We have logged in.
// Update token storage
const tokenStore = useTokenStore();
tokenStore.login(token);
// Go to home page.
goToHome();
} else {
// Show login error.
messagebox.value?.show("Fail to login. Please check your username or password.");
}
// Enable all UI
isLoggingIn.value = false;
}
</script>
<template>
<div style="margin-top: 1.25rem; width: 100%; display: flex; justify-content: center; align-items: center;">
<div class="card" style="padding: 1.25rem;">
<div class="field">
<label class="label">User Name</label>
<div class="control has-icons-left has-icons-right">
<input v-model="username" :disabled="isLoggingIn" class="input" type="text">
<span class="icon is-small is-left">
<font-awesome-icon icon="fas fa-user"></font-awesome-icon>
</span>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<p class="control has-icons-left">
<input v-model="password" :disabled="isLoggingIn" class="input" type="password">
<span class="icon is-small is-left">
<font-awesome-icon icon="fas fa-lock"></font-awesome-icon>
</span>
</p>
</div>
<div class="control">
<button class="button is-primary" :disabled="isLoggingIn" @click="login">Login</button>
</div>
</div>
</div>
<MessageBox ref="messagebox"/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts"></script>
<template>
<div class="container">
<article>
<h1 class="title">Oops!</h1>
<p>You are wandering in the desert of coconut-leaf.</p>
<p>Please back to previous page and try again.</p>
</article>
</div>
</template>
<style scoped>
div.container {
margin-top: 1.25rem;
}
</style>

View File

@@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<h1>Congratulations</h1>
<p>This is todo.</p>
</template>
<style scoped></style>

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

11
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

38
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
// 项目基础路径(对应 Nginx 路由到的 /web 前缀)
base: '/web/',
server: {
port: 5173, // 开发服务器默认端口
open: '/web/', // 启动时自动打开 /web 路径
// 核心:代理配置(对应 Nginx 的 /api -> Go 服务)
proxy: {
// 精细控制路由,对齐生产环境
'^/api/(.*)$': {
target: 'http://127.0.0.1:8848',
changeOrigin: true,
// 路径重写/api/user -> /user
rewrite: (path) => path.replace(/^\/api\//, '/'),
}
},
},
})