From 0fef309972abd62f3cae3ced115701837192e18a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=86OvO?= <1261686748@qq.com> Date: Thu, 6 Aug 2026 11:11:11 +0800 Subject: [PATCH] First Push --- .dockerignore | 11 + .gitignore | 49 + ContentView.swift | 357 +++++ Dockerfile | 19 + LICENSE | 21 + README.md | 272 ++++ backend/__init__.py | 0 backend/alarm_mgr.py | 142 ++ backend/app.py | 110 ++ backend/auth.py | 446 ++++++ backend/calendar_mgr.py | 471 ++++++ backend/countdown_mgr.py | 119 ++ backend/events.py | 49 + backend/jwt_util.py | 55 + backend/models.py | 774 ++++++++++ backend/pomodoro_mgr.py | 312 ++++ backend/routes.py | 1672 ++++++++++++++++++++++ backend/scheduler.py | 86 ++ backend/sun_util.py | 114 ++ backend/time_util.py | 267 ++++ backend/url_util.py | 86 ++ backend/weather_mgr.py | 680 +++++++++ backend/webauthn_mgr.py | 255 ++++ docker-compose.yml | 20 + frontend/static/css/control.css | 350 +++++ frontend/static/css/display.css | 867 +++++++++++ frontend/static/icons/100-fill.svg | 3 + frontend/static/icons/100.svg | 3 + frontend/static/icons/1001.svg | 3 + frontend/static/icons/1002.svg | 5 + frontend/static/icons/1003.svg | 3 + frontend/static/icons/1004.svg | 3 + frontend/static/icons/1005.svg | 4 + frontend/static/icons/1006.svg | 4 + frontend/static/icons/1007.svg | 4 + frontend/static/icons/1008.svg | 5 + frontend/static/icons/1009.svg | 4 + frontend/static/icons/101-fill.svg | 3 + frontend/static/icons/101.svg | 3 + frontend/static/icons/1010.svg | 3 + frontend/static/icons/1011.svg | 4 + frontend/static/icons/1012.svg | 3 + frontend/static/icons/1013.svg | 4 + frontend/static/icons/1014.svg | 3 + frontend/static/icons/1015.svg | 3 + frontend/static/icons/1016.svg | 4 + frontend/static/icons/1017.svg | 3 + frontend/static/icons/1018.svg | 3 + frontend/static/icons/1019.svg | 3 + frontend/static/icons/102-fill.svg | 4 + frontend/static/icons/102.svg | 3 + frontend/static/icons/1020.svg | 4 + frontend/static/icons/1021.svg | 4 + frontend/static/icons/1022.svg | 3 + frontend/static/icons/1023.svg | 3 + frontend/static/icons/1024.svg | 3 + frontend/static/icons/1025.svg | 4 + frontend/static/icons/1026.svg | 4 + frontend/static/icons/1027.svg | 4 + frontend/static/icons/1028.svg | 4 + frontend/static/icons/1029.svg | 5 + frontend/static/icons/103-fill.svg | 3 + frontend/static/icons/103.svg | 3 + frontend/static/icons/1030.svg | 6 + frontend/static/icons/1031.svg | 3 + frontend/static/icons/1032.svg | 4 + frontend/static/icons/1033.svg | 3 + frontend/static/icons/1034.svg | 4 + frontend/static/icons/1035.svg | 3 + frontend/static/icons/1036.svg | 4 + frontend/static/icons/1037.svg | 3 + frontend/static/icons/1038.svg | 3 + frontend/static/icons/1039.svg | 5 + frontend/static/icons/104-fill.svg | 4 + frontend/static/icons/104.svg | 4 + frontend/static/icons/1040.svg | 3 + frontend/static/icons/1041.svg | 5 + frontend/static/icons/1042.svg | 4 + frontend/static/icons/1043.svg | 3 + frontend/static/icons/1044.svg | 4 + frontend/static/icons/1045.svg | 4 + frontend/static/icons/1046.svg | 3 + frontend/static/icons/1047.svg | 3 + frontend/static/icons/1048.svg | 4 + frontend/static/icons/1049.svg | 3 + frontend/static/icons/1050.svg | 4 + frontend/static/icons/1051.svg | 5 + frontend/static/icons/1052.svg | 3 + frontend/static/icons/1053.svg | 4 + frontend/static/icons/1054.svg | 3 + frontend/static/icons/1055.svg | 3 + frontend/static/icons/1056.svg | 5 + frontend/static/icons/1057.svg | 5 + frontend/static/icons/1058.svg | 3 + frontend/static/icons/1059.svg | 6 + frontend/static/icons/1060.svg | 5 + frontend/static/icons/1061.svg | 4 + frontend/static/icons/1062.svg | 4 + frontend/static/icons/1063.svg | 4 + frontend/static/icons/1064.svg | 4 + frontend/static/icons/1065.svg | 3 + frontend/static/icons/1066.svg | 3 + frontend/static/icons/1067.svg | 3 + frontend/static/icons/1068.svg | 4 + frontend/static/icons/1069.svg | 5 + frontend/static/icons/1071.svg | 3 + frontend/static/icons/1072.svg | 4 + frontend/static/icons/1073.svg | 4 + frontend/static/icons/1074.svg | 3 + frontend/static/icons/1075.svg | 4 + frontend/static/icons/1076.svg | 5 + frontend/static/icons/1077.svg | 4 + frontend/static/icons/1078.svg | 3 + frontend/static/icons/1079.svg | 3 + frontend/static/icons/1080.svg | 4 + frontend/static/icons/1081.svg | 4 + frontend/static/icons/1082.svg | 4 + frontend/static/icons/1084.svg | 4 + frontend/static/icons/1085.svg | 4 + frontend/static/icons/1086.svg | 5 + frontend/static/icons/1087.svg | 6 + frontend/static/icons/1088.svg | 4 + frontend/static/icons/1089.svg | 6 + frontend/static/icons/1201.svg | 4 + frontend/static/icons/1202.svg | 4 + frontend/static/icons/1203.svg | 3 + frontend/static/icons/1204.svg | 4 + frontend/static/icons/1205.svg | 6 + frontend/static/icons/1206.svg | 4 + frontend/static/icons/1207.svg | 4 + frontend/static/icons/1208.svg | 3 + frontend/static/icons/1209.svg | 5 + frontend/static/icons/1210.svg | 3 + frontend/static/icons/1211.svg | 4 + frontend/static/icons/1212.svg | 3 + frontend/static/icons/1213.svg | 3 + frontend/static/icons/1214.svg | 3 + frontend/static/icons/1215.svg | 4 + frontend/static/icons/1216.svg | 4 + frontend/static/icons/1217.svg | 3 + frontend/static/icons/1218.svg | 4 + frontend/static/icons/1219.svg | 4 + frontend/static/icons/1221.svg | 4 + frontend/static/icons/1241.svg | 4 + frontend/static/icons/1242.svg | 4 + frontend/static/icons/1243.svg | 4 + frontend/static/icons/1244.svg | 4 + frontend/static/icons/1245.svg | 3 + frontend/static/icons/1246.svg | 3 + frontend/static/icons/1247.svg | 4 + frontend/static/icons/1248.svg | 4 + frontend/static/icons/1249.svg | 3 + frontend/static/icons/1250.svg | 3 + frontend/static/icons/1251.svg | 3 + frontend/static/icons/1271.svg | 4 + frontend/static/icons/1272.svg | 3 + frontend/static/icons/1273.svg | 4 + frontend/static/icons/1274.svg | 4 + frontend/static/icons/150-fill.svg | 3 + frontend/static/icons/150.svg | 3 + frontend/static/icons/151-fill.svg | 3 + frontend/static/icons/151.svg | 3 + frontend/static/icons/152-fill.svg | 3 + frontend/static/icons/152.svg | 3 + frontend/static/icons/153-fill.svg | 4 + frontend/static/icons/153.svg | 4 + frontend/static/icons/1601.svg | 4 + frontend/static/icons/1602.svg | 4 + frontend/static/icons/1603.svg | 4 + frontend/static/icons/1604.svg | 3 + frontend/static/icons/1605.svg | 3 + frontend/static/icons/1606.svg | 4 + frontend/static/icons/1607.svg | 4 + frontend/static/icons/1608.svg | 4 + frontend/static/icons/1609.svg | 3 + frontend/static/icons/1610.svg | 4 + frontend/static/icons/1701.svg | 6 + frontend/static/icons/1702.svg | 4 + frontend/static/icons/1703.svg | 4 + frontend/static/icons/1704.svg | 5 + frontend/static/icons/1705.svg | 4 + frontend/static/icons/1706.svg | 4 + frontend/static/icons/1707.svg | 6 + frontend/static/icons/1708.svg | 5 + frontend/static/icons/1709.svg | 3 + frontend/static/icons/1710.svg | 4 + frontend/static/icons/1801.svg | 3 + frontend/static/icons/1802.svg | 3 + frontend/static/icons/1803.svg | 3 + frontend/static/icons/1804.svg | 3 + frontend/static/icons/1805.svg | 4 + frontend/static/icons/2001.svg | 4 + frontend/static/icons/2002.svg | 3 + frontend/static/icons/2003.svg | 3 + frontend/static/icons/2004.svg | 3 + frontend/static/icons/2005.svg | 3 + frontend/static/icons/2006.svg | 4 + frontend/static/icons/2007.svg | 3 + frontend/static/icons/2029.svg | 3 + frontend/static/icons/2030.svg | 4 + frontend/static/icons/2031.svg | 4 + frontend/static/icons/2032.svg | 4 + frontend/static/icons/2033.svg | 3 + frontend/static/icons/2050.svg | 3 + frontend/static/icons/2051.svg | 4 + frontend/static/icons/2052.svg | 3 + frontend/static/icons/2053.svg | 4 + frontend/static/icons/2054.svg | 3 + frontend/static/icons/2070.svg | 4 + frontend/static/icons/2071.svg | 4 + frontend/static/icons/2072.svg | 5 + frontend/static/icons/2073.svg | 5 + frontend/static/icons/2074.svg | 4 + frontend/static/icons/2075.svg | 4 + frontend/static/icons/2076.svg | 3 + frontend/static/icons/2077.svg | 3 + frontend/static/icons/2078.svg | 3 + frontend/static/icons/2079.svg | 3 + frontend/static/icons/2080.svg | 5 + frontend/static/icons/2081.svg | 4 + frontend/static/icons/2082.svg | 5 + frontend/static/icons/2083.svg | 3 + frontend/static/icons/2084.svg | 3 + frontend/static/icons/2085.svg | 4 + frontend/static/icons/2100.svg | 3 + frontend/static/icons/2101.svg | 3 + frontend/static/icons/2102.svg | 3 + frontend/static/icons/2103.svg | 3 + frontend/static/icons/2104.svg | 3 + frontend/static/icons/2105.svg | 3 + frontend/static/icons/2106.svg | 4 + frontend/static/icons/2107.svg | 3 + frontend/static/icons/2108.svg | 3 + frontend/static/icons/2109.svg | 4 + frontend/static/icons/2111.svg | 5 + frontend/static/icons/2120.svg | 4 + frontend/static/icons/2121.svg | 4 + frontend/static/icons/2122.svg | 3 + frontend/static/icons/2123.svg | 3 + frontend/static/icons/2124.svg | 3 + frontend/static/icons/2125.svg | 3 + frontend/static/icons/2126.svg | 3 + frontend/static/icons/2127.svg | 4 + frontend/static/icons/2128.svg | 3 + frontend/static/icons/2129.svg | 3 + frontend/static/icons/2130.svg | 3 + frontend/static/icons/2131.svg | 3 + frontend/static/icons/2132.svg | 3 + frontend/static/icons/2133.svg | 3 + frontend/static/icons/2134.svg | 4 + frontend/static/icons/2135.svg | 3 + frontend/static/icons/2150.svg | 3 + frontend/static/icons/2151.svg | 4 + frontend/static/icons/2152.svg | 4 + frontend/static/icons/2153.svg | 3 + frontend/static/icons/2154.svg | 3 + frontend/static/icons/2155.svg | 4 + frontend/static/icons/2156.svg | 4 + frontend/static/icons/2157.svg | 4 + frontend/static/icons/2158.svg | 4 + frontend/static/icons/2159.svg | 4 + frontend/static/icons/2160.svg | 3 + frontend/static/icons/2161.svg | 4 + frontend/static/icons/2162.svg | 4 + frontend/static/icons/2163.svg | 3 + frontend/static/icons/2164.svg | 4 + frontend/static/icons/2165.svg | 4 + frontend/static/icons/2166.svg | 4 + frontend/static/icons/2167.svg | 3 + frontend/static/icons/2190.svg | 3 + frontend/static/icons/2191.svg | 4 + frontend/static/icons/2192.svg | 4 + frontend/static/icons/2193.svg | 5 + frontend/static/icons/2200.svg | 3 + frontend/static/icons/2201.svg | 4 + frontend/static/icons/2202.svg | 3 + frontend/static/icons/2203.svg | 4 + frontend/static/icons/2204.svg | 3 + frontend/static/icons/2205.svg | 5 + frontend/static/icons/2207.svg | 4 + frontend/static/icons/2208.svg | 4 + frontend/static/icons/2209.svg | 3 + frontend/static/icons/2210.svg | 3 + frontend/static/icons/2211.svg | 3 + frontend/static/icons/2212.svg | 5 + frontend/static/icons/2213.svg | 4 + frontend/static/icons/2214.svg | 4 + frontend/static/icons/2215.svg | 3 + frontend/static/icons/2216.svg | 3 + frontend/static/icons/2217.svg | 4 + frontend/static/icons/2218.svg | 4 + frontend/static/icons/2300.svg | 3 + frontend/static/icons/2301.svg | 3 + frontend/static/icons/2302.svg | 4 + frontend/static/icons/2303.svg | 4 + frontend/static/icons/2304.svg | 6 + frontend/static/icons/2305.svg | 6 + frontend/static/icons/2306.svg | 6 + frontend/static/icons/2307.svg | 3 + frontend/static/icons/2308.svg | 4 + frontend/static/icons/2309.svg | 3 + frontend/static/icons/2311.svg | 4 + frontend/static/icons/2312.svg | 4 + frontend/static/icons/2313.svg | 3 + frontend/static/icons/2314.svg | 4 + frontend/static/icons/2315.svg | 4 + frontend/static/icons/2316.svg | 4 + frontend/static/icons/2317.svg | 4 + frontend/static/icons/2318.svg | 3 + frontend/static/icons/2319.svg | 4 + frontend/static/icons/2320.svg | 5 + frontend/static/icons/2321.svg | 3 + frontend/static/icons/2322.svg | 3 + frontend/static/icons/2323.svg | 4 + frontend/static/icons/2324.svg | 3 + frontend/static/icons/2325.svg | 6 + frontend/static/icons/2326.svg | 4 + frontend/static/icons/2327.svg | 3 + frontend/static/icons/2328.svg | 4 + frontend/static/icons/2330.svg | 4 + frontend/static/icons/2331.svg | 3 + frontend/static/icons/2332.svg | 4 + frontend/static/icons/2333.svg | 3 + frontend/static/icons/2341.svg | 4 + frontend/static/icons/2343.svg | 4 + frontend/static/icons/2345.svg | 4 + frontend/static/icons/2346.svg | 3 + frontend/static/icons/2348.svg | 4 + frontend/static/icons/2349.svg | 4 + frontend/static/icons/2350.svg | 4 + frontend/static/icons/2351.svg | 6 + frontend/static/icons/2352.svg | 4 + frontend/static/icons/2353.svg | 4 + frontend/static/icons/2354.svg | 4 + frontend/static/icons/2355.svg | 4 + frontend/static/icons/2356.svg | 4 + frontend/static/icons/2357.svg | 4 + frontend/static/icons/2358.svg | 4 + frontend/static/icons/2359.svg | 3 + frontend/static/icons/2360.svg | 3 + frontend/static/icons/2361.svg | 3 + frontend/static/icons/2362.svg | 3 + frontend/static/icons/2363.svg | 4 + frontend/static/icons/2364.svg | 3 + frontend/static/icons/2365.svg | 4 + frontend/static/icons/2366.svg | 4 + frontend/static/icons/2367.svg | 4 + frontend/static/icons/2368.svg | 3 + frontend/static/icons/2369.svg | 3 + frontend/static/icons/2370.svg | 4 + frontend/static/icons/2371.svg | 4 + frontend/static/icons/2372.svg | 3 + frontend/static/icons/2373.svg | 4 + frontend/static/icons/2374.svg | 3 + frontend/static/icons/2375.svg | 5 + frontend/static/icons/2376.svg | 3 + frontend/static/icons/2377.svg | 3 + frontend/static/icons/2378.svg | 5 + frontend/static/icons/2379.svg | 5 + frontend/static/icons/2380.svg | 5 + frontend/static/icons/2381.svg | 4 + frontend/static/icons/2382.svg | 4 + frontend/static/icons/2383.svg | 4 + frontend/static/icons/2384.svg | 5 + frontend/static/icons/2385.svg | 3 + frontend/static/icons/2386.svg | 5 + frontend/static/icons/2387.svg | 4 + frontend/static/icons/2388.svg | 4 + frontend/static/icons/2389.svg | 6 + frontend/static/icons/2390.svg | 4 + frontend/static/icons/2391.svg | 5 + frontend/static/icons/2392.svg | 5 + frontend/static/icons/2393.svg | 4 + frontend/static/icons/2394.svg | 4 + frontend/static/icons/2395.svg | 4 + frontend/static/icons/2396.svg | 4 + frontend/static/icons/2397.svg | 4 + frontend/static/icons/2398.svg | 4 + frontend/static/icons/2399.svg | 5 + frontend/static/icons/2400.svg | 4 + frontend/static/icons/2409.svg | 4 + frontend/static/icons/2411.svg | 4 + frontend/static/icons/2412.svg | 4 + frontend/static/icons/2413.svg | 3 + frontend/static/icons/2414.svg | 3 + frontend/static/icons/2415.svg | 5 + frontend/static/icons/2416.svg | 3 + frontend/static/icons/2417.svg | 4 + frontend/static/icons/2418.svg | 5 + frontend/static/icons/2419.svg | 4 + frontend/static/icons/2420.svg | 4 + frontend/static/icons/2421.svg | 4 + frontend/static/icons/2422.svg | 4 + frontend/static/icons/2423.svg | 4 + frontend/static/icons/2424.svg | 3 + frontend/static/icons/2425.svg | 4 + frontend/static/icons/2426.svg | 4 + frontend/static/icons/2501.svg | 3 + frontend/static/icons/2502.svg | 3 + frontend/static/icons/2521.svg | 4 + frontend/static/icons/2522.svg | 4 + frontend/static/icons/2523.svg | 5 + frontend/static/icons/2524.svg | 3 + frontend/static/icons/2525.svg | 3 + frontend/static/icons/2526.svg | 3 + frontend/static/icons/2527.svg | 4 + frontend/static/icons/2528.svg | 3 + frontend/static/icons/2529.svg | 3 + frontend/static/icons/2530.svg | 3 + frontend/static/icons/2531.svg | 5 + frontend/static/icons/2532.svg | 3 + frontend/static/icons/2550.svg | 3 + frontend/static/icons/2551.svg | 4 + frontend/static/icons/2552.svg | 3 + frontend/static/icons/2553.svg | 3 + frontend/static/icons/2554.svg | 3 + frontend/static/icons/300-fill.svg | 3 + frontend/static/icons/300.svg | 3 + frontend/static/icons/301-fill.svg | 3 + frontend/static/icons/301.svg | 3 + frontend/static/icons/302-fill.svg | 3 + frontend/static/icons/302.svg | 3 + frontend/static/icons/303-fill.svg | 3 + frontend/static/icons/303.svg | 3 + frontend/static/icons/304-fill.svg | 3 + frontend/static/icons/304.svg | 3 + frontend/static/icons/305-fill.svg | 3 + frontend/static/icons/305.svg | 3 + frontend/static/icons/306-fill.svg | 3 + frontend/static/icons/306.svg | 3 + frontend/static/icons/307-fill.svg | 3 + frontend/static/icons/307.svg | 3 + frontend/static/icons/308-fill.svg | 3 + frontend/static/icons/308.svg | 3 + frontend/static/icons/309-fill.svg | 3 + frontend/static/icons/309.svg | 3 + frontend/static/icons/310-fill.svg | 3 + frontend/static/icons/310.svg | 3 + frontend/static/icons/311-fill.svg | 3 + frontend/static/icons/311.svg | 3 + frontend/static/icons/312-fill.svg | 3 + frontend/static/icons/312.svg | 3 + frontend/static/icons/313-fill.svg | 3 + frontend/static/icons/313.svg | 3 + frontend/static/icons/314-fill.svg | 4 + frontend/static/icons/314.svg | 5 + frontend/static/icons/315-fill.svg | 4 + frontend/static/icons/315.svg | 5 + frontend/static/icons/316-fill.svg | 4 + frontend/static/icons/316.svg | 5 + frontend/static/icons/317-fill.svg | 4 + frontend/static/icons/317.svg | 5 + frontend/static/icons/318-fill.svg | 4 + frontend/static/icons/318.svg | 5 + frontend/static/icons/350-fill.svg | 3 + frontend/static/icons/350.svg | 3 + frontend/static/icons/351-fill.svg | 3 + frontend/static/icons/351.svg | 3 + frontend/static/icons/399-fill.svg | 3 + frontend/static/icons/399.svg | 3 + frontend/static/icons/400-fill.svg | 3 + frontend/static/icons/400.svg | 3 + frontend/static/icons/401-fill.svg | 3 + frontend/static/icons/401.svg | 3 + frontend/static/icons/402-fill.svg | 3 + frontend/static/icons/402.svg | 3 + frontend/static/icons/403-fill.svg | 3 + frontend/static/icons/403.svg | 3 + frontend/static/icons/404-fill.svg | 3 + frontend/static/icons/404.svg | 3 + frontend/static/icons/405-fill.svg | 3 + frontend/static/icons/405.svg | 3 + frontend/static/icons/406-fill.svg | 3 + frontend/static/icons/406.svg | 3 + frontend/static/icons/407-fill.svg | 3 + frontend/static/icons/407.svg | 3 + frontend/static/icons/408-fill.svg | 4 + frontend/static/icons/408.svg | 5 + frontend/static/icons/409-fill.svg | 4 + frontend/static/icons/409.svg | 5 + frontend/static/icons/410-fill.svg | 4 + frontend/static/icons/410.svg | 5 + frontend/static/icons/456-fill.svg | 3 + frontend/static/icons/456.svg | 3 + frontend/static/icons/457-fill.svg | 3 + frontend/static/icons/457.svg | 3 + frontend/static/icons/499-fill.svg | 3 + frontend/static/icons/499.svg | 3 + frontend/static/icons/500-fill.svg | 3 + frontend/static/icons/500.svg | 3 + frontend/static/icons/501-fill.svg | 3 + frontend/static/icons/501.svg | 3 + frontend/static/icons/502-fill.svg | 3 + frontend/static/icons/502.svg | 3 + frontend/static/icons/503-fill.svg | 4 + frontend/static/icons/503.svg | 4 + frontend/static/icons/504-fill.svg | 4 + frontend/static/icons/504.svg | 4 + frontend/static/icons/507-fill.svg | 4 + frontend/static/icons/507.svg | 4 + frontend/static/icons/508-fill.svg | 4 + frontend/static/icons/508.svg | 4 + frontend/static/icons/509-fill.svg | 3 + frontend/static/icons/509.svg | 3 + frontend/static/icons/510-fill.svg | 3 + frontend/static/icons/510.svg | 4 + frontend/static/icons/511-fill.svg | 3 + frontend/static/icons/511.svg | 3 + frontend/static/icons/512-fill.svg | 3 + frontend/static/icons/512.svg | 3 + frontend/static/icons/513-fill.svg | 3 + frontend/static/icons/513.svg | 3 + frontend/static/icons/514-fill.svg | 3 + frontend/static/icons/514.svg | 4 + frontend/static/icons/515-fill.svg | 3 + frontend/static/icons/515.svg | 4 + frontend/static/icons/800.svg | 3 + frontend/static/icons/801.svg | 3 + frontend/static/icons/802.svg | 3 + frontend/static/icons/803.svg | 3 + frontend/static/icons/804.svg | 3 + frontend/static/icons/805.svg | 3 + frontend/static/icons/806.svg | 3 + frontend/static/icons/807.svg | 3 + frontend/static/icons/900-fill.svg | 3 + frontend/static/icons/900.svg | 4 + frontend/static/icons/901-fill.svg | 3 + frontend/static/icons/901.svg | 4 + frontend/static/icons/999-fill.svg | 3 + frontend/static/icons/999.svg | 4 + frontend/static/icons/9999.svg | 3 + frontend/static/icons/qweather-fill.svg | 4 + frontend/static/icons/qweather.svg | 3 + frontend/static/img/kitty.png | Bin 0 -> 63900 bytes frontend/static/js/control.js | 1392 ++++++++++++++++++ frontend/static/js/display.js | 1603 +++++++++++++++++++++ frontend/static/js/passkey_bridge.js | 145 ++ frontend/static/js/theme.js | 64 + frontend/static/ringtones/null | 0 frontend/templates/control.html | 395 +++++ frontend/templates/display.html | 248 ++++ frontend/templates/display_custom.html | 172 +++ frontend/templates/login.html | 434 ++++++ frontend/templates/login_control.html | 401 ++++++ frontend/templates/mfa_setup.html | 456 ++++++ frontend/templates/passkey.html | 85 ++ frontend/templates/register_request.html | 151 ++ requirements.txt | 12 + run.py | 40 + start.py | 23 + tests/test_multi_user.py | 716 +++++++++ 551 files changed, 15793 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 ContentView.swift create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/alarm_mgr.py create mode 100644 backend/app.py create mode 100644 backend/auth.py create mode 100644 backend/calendar_mgr.py create mode 100644 backend/countdown_mgr.py create mode 100644 backend/events.py create mode 100644 backend/jwt_util.py create mode 100644 backend/models.py create mode 100644 backend/pomodoro_mgr.py create mode 100644 backend/routes.py create mode 100644 backend/scheduler.py create mode 100644 backend/sun_util.py create mode 100644 backend/time_util.py create mode 100644 backend/url_util.py create mode 100644 backend/weather_mgr.py create mode 100644 backend/webauthn_mgr.py create mode 100644 docker-compose.yml create mode 100644 frontend/static/css/control.css create mode 100644 frontend/static/css/display.css create mode 100644 frontend/static/icons/100-fill.svg create mode 100644 frontend/static/icons/100.svg create mode 100644 frontend/static/icons/1001.svg create mode 100644 frontend/static/icons/1002.svg create mode 100644 frontend/static/icons/1003.svg create mode 100644 frontend/static/icons/1004.svg create mode 100644 frontend/static/icons/1005.svg create mode 100644 frontend/static/icons/1006.svg create mode 100644 frontend/static/icons/1007.svg create mode 100644 frontend/static/icons/1008.svg create mode 100644 frontend/static/icons/1009.svg create mode 100644 frontend/static/icons/101-fill.svg create mode 100644 frontend/static/icons/101.svg create mode 100644 frontend/static/icons/1010.svg create mode 100644 frontend/static/icons/1011.svg create mode 100644 frontend/static/icons/1012.svg create mode 100644 frontend/static/icons/1013.svg create mode 100644 frontend/static/icons/1014.svg create mode 100644 frontend/static/icons/1015.svg create mode 100644 frontend/static/icons/1016.svg create mode 100644 frontend/static/icons/1017.svg create mode 100644 frontend/static/icons/1018.svg create mode 100644 frontend/static/icons/1019.svg create mode 100644 frontend/static/icons/102-fill.svg create mode 100644 frontend/static/icons/102.svg create mode 100644 frontend/static/icons/1020.svg create mode 100644 frontend/static/icons/1021.svg create mode 100644 frontend/static/icons/1022.svg create mode 100644 frontend/static/icons/1023.svg create mode 100644 frontend/static/icons/1024.svg create mode 100644 frontend/static/icons/1025.svg create mode 100644 frontend/static/icons/1026.svg create mode 100644 frontend/static/icons/1027.svg create mode 100644 frontend/static/icons/1028.svg create mode 100644 frontend/static/icons/1029.svg create mode 100644 frontend/static/icons/103-fill.svg create mode 100644 frontend/static/icons/103.svg create mode 100644 frontend/static/icons/1030.svg create mode 100644 frontend/static/icons/1031.svg create mode 100644 frontend/static/icons/1032.svg create mode 100644 frontend/static/icons/1033.svg create mode 100644 frontend/static/icons/1034.svg create mode 100644 frontend/static/icons/1035.svg create mode 100644 frontend/static/icons/1036.svg create mode 100644 frontend/static/icons/1037.svg create mode 100644 frontend/static/icons/1038.svg create mode 100644 frontend/static/icons/1039.svg create mode 100644 frontend/static/icons/104-fill.svg create mode 100644 frontend/static/icons/104.svg create mode 100644 frontend/static/icons/1040.svg create mode 100644 frontend/static/icons/1041.svg create mode 100644 frontend/static/icons/1042.svg create mode 100644 frontend/static/icons/1043.svg create mode 100644 frontend/static/icons/1044.svg create mode 100644 frontend/static/icons/1045.svg create mode 100644 frontend/static/icons/1046.svg create mode 100644 frontend/static/icons/1047.svg create mode 100644 frontend/static/icons/1048.svg create mode 100644 frontend/static/icons/1049.svg create mode 100644 frontend/static/icons/1050.svg create mode 100644 frontend/static/icons/1051.svg create mode 100644 frontend/static/icons/1052.svg create mode 100644 frontend/static/icons/1053.svg create mode 100644 frontend/static/icons/1054.svg create mode 100644 frontend/static/icons/1055.svg create mode 100644 frontend/static/icons/1056.svg create mode 100644 frontend/static/icons/1057.svg create mode 100644 frontend/static/icons/1058.svg create mode 100644 frontend/static/icons/1059.svg create mode 100644 frontend/static/icons/1060.svg create mode 100644 frontend/static/icons/1061.svg create mode 100644 frontend/static/icons/1062.svg create mode 100644 frontend/static/icons/1063.svg create mode 100644 frontend/static/icons/1064.svg create mode 100644 frontend/static/icons/1065.svg create mode 100644 frontend/static/icons/1066.svg create mode 100644 frontend/static/icons/1067.svg create mode 100644 frontend/static/icons/1068.svg create mode 100644 frontend/static/icons/1069.svg create mode 100644 frontend/static/icons/1071.svg create mode 100644 frontend/static/icons/1072.svg create mode 100644 frontend/static/icons/1073.svg create mode 100644 frontend/static/icons/1074.svg create mode 100644 frontend/static/icons/1075.svg create mode 100644 frontend/static/icons/1076.svg create mode 100644 frontend/static/icons/1077.svg create mode 100644 frontend/static/icons/1078.svg create mode 100644 frontend/static/icons/1079.svg create mode 100644 frontend/static/icons/1080.svg create mode 100644 frontend/static/icons/1081.svg create mode 100644 frontend/static/icons/1082.svg create mode 100644 frontend/static/icons/1084.svg create mode 100644 frontend/static/icons/1085.svg create mode 100644 frontend/static/icons/1086.svg create mode 100644 frontend/static/icons/1087.svg create mode 100644 frontend/static/icons/1088.svg create mode 100644 frontend/static/icons/1089.svg create mode 100644 frontend/static/icons/1201.svg create mode 100644 frontend/static/icons/1202.svg create mode 100644 frontend/static/icons/1203.svg create mode 100644 frontend/static/icons/1204.svg create mode 100644 frontend/static/icons/1205.svg create mode 100644 frontend/static/icons/1206.svg create mode 100644 frontend/static/icons/1207.svg create mode 100644 frontend/static/icons/1208.svg create mode 100644 frontend/static/icons/1209.svg create mode 100644 frontend/static/icons/1210.svg create mode 100644 frontend/static/icons/1211.svg create mode 100644 frontend/static/icons/1212.svg create mode 100644 frontend/static/icons/1213.svg create mode 100644 frontend/static/icons/1214.svg create mode 100644 frontend/static/icons/1215.svg create mode 100644 frontend/static/icons/1216.svg create mode 100644 frontend/static/icons/1217.svg create mode 100644 frontend/static/icons/1218.svg create mode 100644 frontend/static/icons/1219.svg create mode 100644 frontend/static/icons/1221.svg create mode 100644 frontend/static/icons/1241.svg create mode 100644 frontend/static/icons/1242.svg create mode 100644 frontend/static/icons/1243.svg create mode 100644 frontend/static/icons/1244.svg create mode 100644 frontend/static/icons/1245.svg create mode 100644 frontend/static/icons/1246.svg create mode 100644 frontend/static/icons/1247.svg create mode 100644 frontend/static/icons/1248.svg create mode 100644 frontend/static/icons/1249.svg create mode 100644 frontend/static/icons/1250.svg create mode 100644 frontend/static/icons/1251.svg create mode 100644 frontend/static/icons/1271.svg create mode 100644 frontend/static/icons/1272.svg create mode 100644 frontend/static/icons/1273.svg create mode 100644 frontend/static/icons/1274.svg create mode 100644 frontend/static/icons/150-fill.svg create mode 100644 frontend/static/icons/150.svg create mode 100644 frontend/static/icons/151-fill.svg create mode 100644 frontend/static/icons/151.svg create mode 100644 frontend/static/icons/152-fill.svg create mode 100644 frontend/static/icons/152.svg create mode 100644 frontend/static/icons/153-fill.svg create mode 100644 frontend/static/icons/153.svg create mode 100644 frontend/static/icons/1601.svg create mode 100644 frontend/static/icons/1602.svg create mode 100644 frontend/static/icons/1603.svg create mode 100644 frontend/static/icons/1604.svg create mode 100644 frontend/static/icons/1605.svg create mode 100644 frontend/static/icons/1606.svg create mode 100644 frontend/static/icons/1607.svg create mode 100644 frontend/static/icons/1608.svg create mode 100644 frontend/static/icons/1609.svg create mode 100644 frontend/static/icons/1610.svg create mode 100644 frontend/static/icons/1701.svg create mode 100644 frontend/static/icons/1702.svg create mode 100644 frontend/static/icons/1703.svg create mode 100644 frontend/static/icons/1704.svg create mode 100644 frontend/static/icons/1705.svg create mode 100644 frontend/static/icons/1706.svg create mode 100644 frontend/static/icons/1707.svg create mode 100644 frontend/static/icons/1708.svg create mode 100644 frontend/static/icons/1709.svg create mode 100644 frontend/static/icons/1710.svg create mode 100644 frontend/static/icons/1801.svg create mode 100644 frontend/static/icons/1802.svg create mode 100644 frontend/static/icons/1803.svg create mode 100644 frontend/static/icons/1804.svg create mode 100644 frontend/static/icons/1805.svg create mode 100644 frontend/static/icons/2001.svg create mode 100644 frontend/static/icons/2002.svg create mode 100644 frontend/static/icons/2003.svg create mode 100644 frontend/static/icons/2004.svg create mode 100644 frontend/static/icons/2005.svg create mode 100644 frontend/static/icons/2006.svg create mode 100644 frontend/static/icons/2007.svg create mode 100644 frontend/static/icons/2029.svg create mode 100644 frontend/static/icons/2030.svg create mode 100644 frontend/static/icons/2031.svg create mode 100644 frontend/static/icons/2032.svg create mode 100644 frontend/static/icons/2033.svg create mode 100644 frontend/static/icons/2050.svg create mode 100644 frontend/static/icons/2051.svg create mode 100644 frontend/static/icons/2052.svg create mode 100644 frontend/static/icons/2053.svg create mode 100644 frontend/static/icons/2054.svg create mode 100644 frontend/static/icons/2070.svg create mode 100644 frontend/static/icons/2071.svg create mode 100644 frontend/static/icons/2072.svg create mode 100644 frontend/static/icons/2073.svg create mode 100644 frontend/static/icons/2074.svg create mode 100644 frontend/static/icons/2075.svg create mode 100644 frontend/static/icons/2076.svg create mode 100644 frontend/static/icons/2077.svg create mode 100644 frontend/static/icons/2078.svg create mode 100644 frontend/static/icons/2079.svg create mode 100644 frontend/static/icons/2080.svg create mode 100644 frontend/static/icons/2081.svg create mode 100644 frontend/static/icons/2082.svg create mode 100644 frontend/static/icons/2083.svg create mode 100644 frontend/static/icons/2084.svg create mode 100644 frontend/static/icons/2085.svg create mode 100644 frontend/static/icons/2100.svg create mode 100644 frontend/static/icons/2101.svg create mode 100644 frontend/static/icons/2102.svg create mode 100644 frontend/static/icons/2103.svg create mode 100644 frontend/static/icons/2104.svg create mode 100644 frontend/static/icons/2105.svg create mode 100644 frontend/static/icons/2106.svg create mode 100644 frontend/static/icons/2107.svg create mode 100644 frontend/static/icons/2108.svg create mode 100644 frontend/static/icons/2109.svg create mode 100644 frontend/static/icons/2111.svg create mode 100644 frontend/static/icons/2120.svg create mode 100644 frontend/static/icons/2121.svg create mode 100644 frontend/static/icons/2122.svg create mode 100644 frontend/static/icons/2123.svg create mode 100644 frontend/static/icons/2124.svg create mode 100644 frontend/static/icons/2125.svg create mode 100644 frontend/static/icons/2126.svg create mode 100644 frontend/static/icons/2127.svg create mode 100644 frontend/static/icons/2128.svg create mode 100644 frontend/static/icons/2129.svg create mode 100644 frontend/static/icons/2130.svg create mode 100644 frontend/static/icons/2131.svg create mode 100644 frontend/static/icons/2132.svg create mode 100644 frontend/static/icons/2133.svg create mode 100644 frontend/static/icons/2134.svg create mode 100644 frontend/static/icons/2135.svg create mode 100644 frontend/static/icons/2150.svg create mode 100644 frontend/static/icons/2151.svg create mode 100644 frontend/static/icons/2152.svg create mode 100644 frontend/static/icons/2153.svg create mode 100644 frontend/static/icons/2154.svg create mode 100644 frontend/static/icons/2155.svg create mode 100644 frontend/static/icons/2156.svg create mode 100644 frontend/static/icons/2157.svg create mode 100644 frontend/static/icons/2158.svg create mode 100644 frontend/static/icons/2159.svg create mode 100644 frontend/static/icons/2160.svg create mode 100644 frontend/static/icons/2161.svg create mode 100644 frontend/static/icons/2162.svg create mode 100644 frontend/static/icons/2163.svg create mode 100644 frontend/static/icons/2164.svg create mode 100644 frontend/static/icons/2165.svg create mode 100644 frontend/static/icons/2166.svg create mode 100644 frontend/static/icons/2167.svg create mode 100644 frontend/static/icons/2190.svg create mode 100644 frontend/static/icons/2191.svg create mode 100644 frontend/static/icons/2192.svg create mode 100644 frontend/static/icons/2193.svg create mode 100644 frontend/static/icons/2200.svg create mode 100644 frontend/static/icons/2201.svg create mode 100644 frontend/static/icons/2202.svg create mode 100644 frontend/static/icons/2203.svg create mode 100644 frontend/static/icons/2204.svg create mode 100644 frontend/static/icons/2205.svg create mode 100644 frontend/static/icons/2207.svg create mode 100644 frontend/static/icons/2208.svg create mode 100644 frontend/static/icons/2209.svg create mode 100644 frontend/static/icons/2210.svg create mode 100644 frontend/static/icons/2211.svg create mode 100644 frontend/static/icons/2212.svg create mode 100644 frontend/static/icons/2213.svg create mode 100644 frontend/static/icons/2214.svg create mode 100644 frontend/static/icons/2215.svg create mode 100644 frontend/static/icons/2216.svg create mode 100644 frontend/static/icons/2217.svg create mode 100644 frontend/static/icons/2218.svg create mode 100644 frontend/static/icons/2300.svg create mode 100644 frontend/static/icons/2301.svg create mode 100644 frontend/static/icons/2302.svg create mode 100644 frontend/static/icons/2303.svg create mode 100644 frontend/static/icons/2304.svg create mode 100644 frontend/static/icons/2305.svg create mode 100644 frontend/static/icons/2306.svg create mode 100644 frontend/static/icons/2307.svg create mode 100644 frontend/static/icons/2308.svg create mode 100644 frontend/static/icons/2309.svg create mode 100644 frontend/static/icons/2311.svg create mode 100644 frontend/static/icons/2312.svg create mode 100644 frontend/static/icons/2313.svg create mode 100644 frontend/static/icons/2314.svg create mode 100644 frontend/static/icons/2315.svg create mode 100644 frontend/static/icons/2316.svg create mode 100644 frontend/static/icons/2317.svg create mode 100644 frontend/static/icons/2318.svg create mode 100644 frontend/static/icons/2319.svg create mode 100644 frontend/static/icons/2320.svg create mode 100644 frontend/static/icons/2321.svg create mode 100644 frontend/static/icons/2322.svg create mode 100644 frontend/static/icons/2323.svg create mode 100644 frontend/static/icons/2324.svg create mode 100644 frontend/static/icons/2325.svg create mode 100644 frontend/static/icons/2326.svg create mode 100644 frontend/static/icons/2327.svg create mode 100644 frontend/static/icons/2328.svg create mode 100644 frontend/static/icons/2330.svg create mode 100644 frontend/static/icons/2331.svg create mode 100644 frontend/static/icons/2332.svg create mode 100644 frontend/static/icons/2333.svg create mode 100644 frontend/static/icons/2341.svg create mode 100644 frontend/static/icons/2343.svg create mode 100644 frontend/static/icons/2345.svg create mode 100644 frontend/static/icons/2346.svg create mode 100644 frontend/static/icons/2348.svg create mode 100644 frontend/static/icons/2349.svg create mode 100644 frontend/static/icons/2350.svg create mode 100644 frontend/static/icons/2351.svg create mode 100644 frontend/static/icons/2352.svg create mode 100644 frontend/static/icons/2353.svg create mode 100644 frontend/static/icons/2354.svg create mode 100644 frontend/static/icons/2355.svg create mode 100644 frontend/static/icons/2356.svg create mode 100644 frontend/static/icons/2357.svg create mode 100644 frontend/static/icons/2358.svg create mode 100644 frontend/static/icons/2359.svg create mode 100644 frontend/static/icons/2360.svg create mode 100644 frontend/static/icons/2361.svg create mode 100644 frontend/static/icons/2362.svg create mode 100644 frontend/static/icons/2363.svg create mode 100644 frontend/static/icons/2364.svg create mode 100644 frontend/static/icons/2365.svg create mode 100644 frontend/static/icons/2366.svg create mode 100644 frontend/static/icons/2367.svg create mode 100644 frontend/static/icons/2368.svg create mode 100644 frontend/static/icons/2369.svg create mode 100644 frontend/static/icons/2370.svg create mode 100644 frontend/static/icons/2371.svg create mode 100644 frontend/static/icons/2372.svg create mode 100644 frontend/static/icons/2373.svg create mode 100644 frontend/static/icons/2374.svg create mode 100644 frontend/static/icons/2375.svg create mode 100644 frontend/static/icons/2376.svg create mode 100644 frontend/static/icons/2377.svg create mode 100644 frontend/static/icons/2378.svg create mode 100644 frontend/static/icons/2379.svg create mode 100644 frontend/static/icons/2380.svg create mode 100644 frontend/static/icons/2381.svg create mode 100644 frontend/static/icons/2382.svg create mode 100644 frontend/static/icons/2383.svg create mode 100644 frontend/static/icons/2384.svg create mode 100644 frontend/static/icons/2385.svg create mode 100644 frontend/static/icons/2386.svg create mode 100644 frontend/static/icons/2387.svg create mode 100644 frontend/static/icons/2388.svg create mode 100644 frontend/static/icons/2389.svg create mode 100644 frontend/static/icons/2390.svg create mode 100644 frontend/static/icons/2391.svg create mode 100644 frontend/static/icons/2392.svg create mode 100644 frontend/static/icons/2393.svg create mode 100644 frontend/static/icons/2394.svg create mode 100644 frontend/static/icons/2395.svg create mode 100644 frontend/static/icons/2396.svg create mode 100644 frontend/static/icons/2397.svg create mode 100644 frontend/static/icons/2398.svg create mode 100644 frontend/static/icons/2399.svg create mode 100644 frontend/static/icons/2400.svg create mode 100644 frontend/static/icons/2409.svg create mode 100644 frontend/static/icons/2411.svg create mode 100644 frontend/static/icons/2412.svg create mode 100644 frontend/static/icons/2413.svg create mode 100644 frontend/static/icons/2414.svg create mode 100644 frontend/static/icons/2415.svg create mode 100644 frontend/static/icons/2416.svg create mode 100644 frontend/static/icons/2417.svg create mode 100644 frontend/static/icons/2418.svg create mode 100644 frontend/static/icons/2419.svg create mode 100644 frontend/static/icons/2420.svg create mode 100644 frontend/static/icons/2421.svg create mode 100644 frontend/static/icons/2422.svg create mode 100644 frontend/static/icons/2423.svg create mode 100644 frontend/static/icons/2424.svg create mode 100644 frontend/static/icons/2425.svg create mode 100644 frontend/static/icons/2426.svg create mode 100644 frontend/static/icons/2501.svg create mode 100644 frontend/static/icons/2502.svg create mode 100644 frontend/static/icons/2521.svg create mode 100644 frontend/static/icons/2522.svg create mode 100644 frontend/static/icons/2523.svg create mode 100644 frontend/static/icons/2524.svg create mode 100644 frontend/static/icons/2525.svg create mode 100644 frontend/static/icons/2526.svg create mode 100644 frontend/static/icons/2527.svg create mode 100644 frontend/static/icons/2528.svg create mode 100644 frontend/static/icons/2529.svg create mode 100644 frontend/static/icons/2530.svg create mode 100644 frontend/static/icons/2531.svg create mode 100644 frontend/static/icons/2532.svg create mode 100644 frontend/static/icons/2550.svg create mode 100644 frontend/static/icons/2551.svg create mode 100644 frontend/static/icons/2552.svg create mode 100644 frontend/static/icons/2553.svg create mode 100644 frontend/static/icons/2554.svg create mode 100644 frontend/static/icons/300-fill.svg create mode 100644 frontend/static/icons/300.svg create mode 100644 frontend/static/icons/301-fill.svg create mode 100644 frontend/static/icons/301.svg create mode 100644 frontend/static/icons/302-fill.svg create mode 100644 frontend/static/icons/302.svg create mode 100644 frontend/static/icons/303-fill.svg create mode 100644 frontend/static/icons/303.svg create mode 100644 frontend/static/icons/304-fill.svg create mode 100644 frontend/static/icons/304.svg create mode 100644 frontend/static/icons/305-fill.svg create mode 100644 frontend/static/icons/305.svg create mode 100644 frontend/static/icons/306-fill.svg create mode 100644 frontend/static/icons/306.svg create mode 100644 frontend/static/icons/307-fill.svg create mode 100644 frontend/static/icons/307.svg create mode 100644 frontend/static/icons/308-fill.svg create mode 100644 frontend/static/icons/308.svg create mode 100644 frontend/static/icons/309-fill.svg create mode 100644 frontend/static/icons/309.svg create mode 100644 frontend/static/icons/310-fill.svg create mode 100644 frontend/static/icons/310.svg create mode 100644 frontend/static/icons/311-fill.svg create mode 100644 frontend/static/icons/311.svg create mode 100644 frontend/static/icons/312-fill.svg create mode 100644 frontend/static/icons/312.svg create mode 100644 frontend/static/icons/313-fill.svg create mode 100644 frontend/static/icons/313.svg create mode 100644 frontend/static/icons/314-fill.svg create mode 100644 frontend/static/icons/314.svg create mode 100644 frontend/static/icons/315-fill.svg create mode 100644 frontend/static/icons/315.svg create mode 100644 frontend/static/icons/316-fill.svg create mode 100644 frontend/static/icons/316.svg create mode 100644 frontend/static/icons/317-fill.svg create mode 100644 frontend/static/icons/317.svg create mode 100644 frontend/static/icons/318-fill.svg create mode 100644 frontend/static/icons/318.svg create mode 100644 frontend/static/icons/350-fill.svg create mode 100644 frontend/static/icons/350.svg create mode 100644 frontend/static/icons/351-fill.svg create mode 100644 frontend/static/icons/351.svg create mode 100644 frontend/static/icons/399-fill.svg create mode 100644 frontend/static/icons/399.svg create mode 100644 frontend/static/icons/400-fill.svg create mode 100644 frontend/static/icons/400.svg create mode 100644 frontend/static/icons/401-fill.svg create mode 100644 frontend/static/icons/401.svg create mode 100644 frontend/static/icons/402-fill.svg create mode 100644 frontend/static/icons/402.svg create mode 100644 frontend/static/icons/403-fill.svg create mode 100644 frontend/static/icons/403.svg create mode 100644 frontend/static/icons/404-fill.svg create mode 100644 frontend/static/icons/404.svg create mode 100644 frontend/static/icons/405-fill.svg create mode 100644 frontend/static/icons/405.svg create mode 100644 frontend/static/icons/406-fill.svg create mode 100644 frontend/static/icons/406.svg create mode 100644 frontend/static/icons/407-fill.svg create mode 100644 frontend/static/icons/407.svg create mode 100644 frontend/static/icons/408-fill.svg create mode 100644 frontend/static/icons/408.svg create mode 100644 frontend/static/icons/409-fill.svg create mode 100644 frontend/static/icons/409.svg create mode 100644 frontend/static/icons/410-fill.svg create mode 100644 frontend/static/icons/410.svg create mode 100644 frontend/static/icons/456-fill.svg create mode 100644 frontend/static/icons/456.svg create mode 100644 frontend/static/icons/457-fill.svg create mode 100644 frontend/static/icons/457.svg create mode 100644 frontend/static/icons/499-fill.svg create mode 100644 frontend/static/icons/499.svg create mode 100644 frontend/static/icons/500-fill.svg create mode 100644 frontend/static/icons/500.svg create mode 100644 frontend/static/icons/501-fill.svg create mode 100644 frontend/static/icons/501.svg create mode 100644 frontend/static/icons/502-fill.svg create mode 100644 frontend/static/icons/502.svg create mode 100644 frontend/static/icons/503-fill.svg create mode 100644 frontend/static/icons/503.svg create mode 100644 frontend/static/icons/504-fill.svg create mode 100644 frontend/static/icons/504.svg create mode 100644 frontend/static/icons/507-fill.svg create mode 100644 frontend/static/icons/507.svg create mode 100644 frontend/static/icons/508-fill.svg create mode 100644 frontend/static/icons/508.svg create mode 100644 frontend/static/icons/509-fill.svg create mode 100644 frontend/static/icons/509.svg create mode 100644 frontend/static/icons/510-fill.svg create mode 100644 frontend/static/icons/510.svg create mode 100644 frontend/static/icons/511-fill.svg create mode 100644 frontend/static/icons/511.svg create mode 100644 frontend/static/icons/512-fill.svg create mode 100644 frontend/static/icons/512.svg create mode 100644 frontend/static/icons/513-fill.svg create mode 100644 frontend/static/icons/513.svg create mode 100644 frontend/static/icons/514-fill.svg create mode 100644 frontend/static/icons/514.svg create mode 100644 frontend/static/icons/515-fill.svg create mode 100644 frontend/static/icons/515.svg create mode 100644 frontend/static/icons/800.svg create mode 100644 frontend/static/icons/801.svg create mode 100644 frontend/static/icons/802.svg create mode 100644 frontend/static/icons/803.svg create mode 100644 frontend/static/icons/804.svg create mode 100644 frontend/static/icons/805.svg create mode 100644 frontend/static/icons/806.svg create mode 100644 frontend/static/icons/807.svg create mode 100644 frontend/static/icons/900-fill.svg create mode 100644 frontend/static/icons/900.svg create mode 100644 frontend/static/icons/901-fill.svg create mode 100644 frontend/static/icons/901.svg create mode 100644 frontend/static/icons/999-fill.svg create mode 100644 frontend/static/icons/999.svg create mode 100644 frontend/static/icons/9999.svg create mode 100644 frontend/static/icons/qweather-fill.svg create mode 100644 frontend/static/icons/qweather.svg create mode 100644 frontend/static/img/kitty.png create mode 100644 frontend/static/js/control.js create mode 100644 frontend/static/js/display.js create mode 100644 frontend/static/js/passkey_bridge.js create mode 100644 frontend/static/js/theme.js create mode 100644 frontend/static/ringtones/null create mode 100644 frontend/templates/control.html create mode 100644 frontend/templates/display.html create mode 100644 frontend/templates/display_custom.html create mode 100644 frontend/templates/login.html create mode 100644 frontend/templates/login_control.html create mode 100644 frontend/templates/mfa_setup.html create mode 100644 frontend/templates/passkey.html create mode 100644 frontend/templates/register_request.html create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 start.py create mode 100644 tests/test_multi_user.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1eecba1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.idea/ +.reasonix/ +__pycache__/ +*.pyc +*.db +*.db.backup +.venv/ +venv/ +env/ +.DS_Store +审计报告.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5e3f03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Database +*.db +*.db-journal +*.db-wal +*.db-shm +*.sqlite +*.sqlite3 +*.db.backup +data.db.pre_upgrade_test +# Virtual environment +.venv/ +venv/ +env/ +ENV/ + +# macOS +.DS_Store +.DS_Store? +._* +.AppleDouble +.LSOverride +.Spotlight-V100 +.Trashes + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg +*.so + +# IDE / editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +*.orig + +# Temp files +*.tmp +*.temp +*.bak +*.log + +.reasonix +prompt.md diff --git a/ContentView.swift b/ContentView.swift new file mode 100644 index 0000000..17b1d25 --- /dev/null +++ b/ContentView.swift @@ -0,0 +1,357 @@ +import SwiftUI +import WebKit +import AuthenticationServices +import UIKit + +// MARK: - AppSettings(域名存储:UserDefaults) +enum AppSettings { + static let baseURLKey = "dashboard_base_url" + + /// 规范化用户输入的域名:补 https://、去尾斜杠、校验格式。 + static func normalizedBaseURL(_ raw: String) -> URL? { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return nil } + if !s.hasPrefix("http://") && !s.hasPrefix("https://") { s = "https://" + s } + while s.hasSuffix("/") { s.removeLast() } + guard let url = URL(string: s), + let scheme = url.scheme, scheme == "http" || scheme == "https", + url.host != nil else { return nil } + return url + } +} + +// MARK: - Coordinator +class WebViewCoordinator: NSObject, WKNavigationDelegate { + weak var webView: WKWebView? + var onPageFinished: (() -> Void)? + var onDashboardIntercepted: ((URL) -> Void)? + var onPasskeyIntercepted: ((URL) -> Void)? + + func load(url: URL) { + // Caller must set onPageFinished BEFORE calling load() + webView?.load(URLRequest(url: url)) + } + + func webView(_: WKWebView, didFinish _: WKNavigation!) { onPageFinished?() } + func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { onPageFinished?() } + func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { onPageFinished?() } + + func webView(_: WKWebView, decidePolicyFor navAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navAction.request.url else { decisionHandler(.allow); return } + // dashboard:// 回调(Passkey 小窗完成/失败) + if url.scheme == "dashboard" { + onDashboardIntercepted?(url) + decisionHandler(.cancel) + return + } + // /login 不再拦截:登录页在 WKWebView 内直接渲染(与网页版一致), + // cookie 直接进入主容器会话;仅当页面内发起 Passkey 时,中间件 + // 跳 /passkey,这里拦截并在 ASWebAuthenticationSession 小窗中打开。 + if url.path == "/passkey" || url.path.hasPrefix("/passkey/") { + onPasskeyIntercepted?(url) + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } +} + +// MARK: - 登录小窗(ASWebAuthenticationSession) +class AuthSessionManager: NSObject, ASWebAuthenticationPresentationContextProviding { + private var currentSession: ASWebAuthenticationSession? + + func start(url: URL, scheme: String, completion: @escaping (URL?) -> Void) { + let s = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { cb, _ in + self.currentSession = nil + completion(cb) + } + // 每次小窗使用全新的临时会话,不含旧 cookie —— 修复"App 已登出但小窗 + // 仍带旧 cookie 直接进入主页 → 无法登录 → 跳回 /login → 无限循环"的问题。 + s.prefersEphemeralWebBrowserSession = true + s.presentationContextProvider = self + currentSession = s + s.start() + } + + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene }.flatMap { $0.windows } + .first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} + +struct WebView: UIViewRepresentable { + let url: URL + let coordinator: WebViewCoordinator + var onPageFinished: (() -> Void)? + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.mediaTypesRequiringUserActionForPlayback = [] + config.allowsInlineMediaPlayback = true + // 注入 iOS 套壳 App 标志:Web 端 Passkey 中间件据此判定"当前运行在 App 内", + // 从而跳转 /passkey 专有页面走原生 ASAuthorizationController。 + // 用注入标志而非覆盖 User-Agent,避免破坏真实 UA 导致服务器/反代误判。 + let script = WKUserScript(source: "window.__CAL_DISPLAY_APP__ = true;", + injectionTime: .atDocumentStart, + forMainFrameOnly: true) + config.userContentController.addUserScript(script) + let wv = WKWebView(frame: .zero, configuration: config) + wv.navigationDelegate = context.coordinator + wv.scrollView.bounces = true + context.coordinator.webView = wv + context.coordinator.onPageFinished = onPageFinished + wv.load(URLRequest(url: url)) + return wv + } + func updateUIView(_: WKWebView, context _: Context) {} + func makeCoordinator() -> WebViewCoordinator { coordinator } +} + +// MARK: - 域名设置 / 欢迎页(iPhone 上下排列,iPad 左右排列) +struct DomainSetupView: View { + @Environment(\.horizontalSizeClass) private var sizeClass + @AppStorage(AppSettings.baseURLKey) private var baseURLString = "" + @State private var input = "" + @State private var showInput = false // false=欢迎页(已有域名);true=填写页 + @State private var error: String? + var onEnter: () -> Void + + private var hasDomain: Bool { !baseURLString.isEmpty } + private var displayURL: String { + AppSettings.normalizedBaseURL(baseURLString)?.absoluteString ?? baseURLString + } + + var body: some View { + ZStack { + Color(red: 15/255, green: 17/255, blue: 23/255).ignoresSafeArea() + // iPhone(compact)上下排列;iPad(regular)左右排列 + if sizeClass == .compact { + VStack(spacing: 24) { content }.padding(32) + } else { + HStack(spacing: 40) { content }.padding(56) + } + } + .onAppear { + showInput = !hasDomain + if hasDomain { input = baseURLString } + } + } + + @ViewBuilder private var content: some View { + if hasDomain && !showInput { + // ─── 欢迎页 ─── + Group { + Text("🎛️").font(.system(size: 48)) + Text("欢迎回来!").font(.largeTitle.weight(.semibold)).foregroundColor(.white) + Text(displayURL).font(.subheadline).foregroundColor(.white.opacity(0.55)) + Button(action: { onEnter() }) { + Text("继续").font(.headline).foregroundColor(.white) + .frame(minWidth: 160).padding(.vertical, 12).padding(.horizontal, 32) + .background(Color(red: 74/255, green: 144/255, blue: 217/255)) + .cornerRadius(12) + } + Button(action: { showInput = true; input = baseURLString }) { + Text("更改域名").font(.subheadline).foregroundColor(.white.opacity(0.7)) + .padding(.vertical, 8).padding(.horizontal, 20) + } + } + } else { + // ─── 填写页 ─── + Group { + Text("您好!请填写连接域名").font(.title2.weight(.semibold)).foregroundColor(.white) + TextField("https://your-domain.com", text: $input) + .textFieldStyle(PlainTextFieldStyle()) + .keyboardType(.URL) + .autocapitalization(.none) + .foregroundColor(.white) + .padding(14) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) + .frame(maxWidth: 420) + if let error = error { + Text(error).font(.footnote).foregroundColor(Color(red: 224/255, green: 85/255, blue: 85/255)) + } + Button(action: save) { + Text("保存").font(.headline).foregroundColor(.white) + .frame(minWidth: 160).padding(.vertical, 12).padding(.horizontal, 32) + .background(Color(red: 74/255, green: 144/255, blue: 217/255)) + .cornerRadius(12) + } + } + } + } + + private func save() { + guard let url = AppSettings.normalizedBaseURL(input) else { + error = "域名格式无效,请输入如 dashboard.example.com" + return + } + baseURLString = url.absoluteString + error = nil + onEnter() + } +} + +// MARK: - MainWebView(主界面:WebView + 登录/Passkey 拦截) +struct MainWebView: View { + let baseURL: URL + @State private var showSplash = true + @State private var coordinator = WebViewCoordinator() + @State private var authSession = AuthSessionManager() + + var body: some View { + ZStack { + WebView( + url: baseURL, + coordinator: coordinator, + onPageFinished: { + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.35)) { showSplash = false } + } + } + ) + .ignoresSafeArea() + .opacity(showSplash ? 0 : 1) + if showSplash { SplashView().transition(.opacity) } + } + .ignoresSafeArea() + .id(baseURL.absoluteString) // 域名变化时重建 WKWebView + .onAppear { + coordinator.onDashboardIntercepted = { [self] in handleDashboard(url: $0) } + coordinator.onPasskeyIntercepted = { [self] in handlePasskey(url: $0) } + } + } + + // dashboard:// 回调:仅 Passkey 小窗使用(登录已改为主容器内完成, + // 不再需要 dashboard://callback + session-token 交换)。 + private func handleDashboard(url callbackURL: URL) { + guard callbackURL.host == "passkey" else { return } + handlePasskeyCallback(callbackURL) + } + + // /passkey 被 WKNavigationDelegate 拦截后:用 ASWebAuthenticationSession 小窗 + // 打开该页面(Safari 环境原生支持 WebAuthn/Passkey,无需 App 关联域名); + // 小窗为 ephemeral 会话(prefersEphemeralWebBrowserSession),每次全新、 + // 不携带旧 cookie,避免会话串扰。 + private func handlePasskey(url passkeyURL: URL) { + DispatchQueue.main.async { + authSession.start(url: passkeyURL, scheme: "dashboard") { [self] cb in + guard let cb = cb else { + // 用户取消小窗 → 通知页面中间件取消 + DispatchQueue.main.async { + coordinator.webView?.evaluateJavaScript( + "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel('认证已取消');", + completionHandler: nil) + } + return + } + handleDashboard(url: cb) // dashboard://passkey?sid=... 或 ?error=... + } + } + } + + // dashboard://passkey 回调:小窗完成(?sid=)或失败(?error=)。 + // credential 较大不随 URL 回传,而是从服务端一次性 result 接口取回。 + private func handlePasskeyCallback(_ url: URL) { + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } + let errorMsg = comps.queryItems?.first(where: { $0.name == "error" })?.value + let sid = comps.queryItems?.first(where: { $0.name == "sid" })?.value + DispatchQueue.main.async { + guard let wv = self.coordinator.webView else { return } + if let errorMsg = errorMsg { + wv.evaluateJavaScript( + "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel(" + Self.jsStringLiteral(errorMsg) + ");", + completionHandler: nil) + } else if let sid = sid { + self.fetchPasskeyResult(sid: sid, webView: wv) + } + } + } + + private func fetchPasskeyResult(sid: String, webView: WKWebView) { + var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + comps.path = "/api/passkey/stash/\(sid)/result" + guard let url = comps.url else { return } + var req = URLRequest(url: url) + req.timeoutInterval = 10 + URLSession.shared.dataTask(with: req) { data, _, error in + DispatchQueue.main.async { + if let error = error { + webView.evaluateJavaScript( + "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel(" + Self.jsStringLiteral(error.localizedDescription) + ");", + completionHandler: nil) + return + } + guard let data = data, + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let credential = obj["credential"], + let credData = try? JSONSerialization.data(withJSONObject: credential), + let json = String(data: credData, encoding: .utf8) else { + webView.evaluateJavaScript( + "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel('Passkey 结果获取失败');", + completionHandler: nil) + return + } + // json 是合法 JSON 字面量,可直接作为 JS 实参 + webView.evaluateJavaScript( + "window.PasskeyBridge && window.PasskeyBridge.__iosComplete && window.PasskeyBridge.__iosComplete(" + json + ");", + completionHandler: nil) + } + }.resume() + } + + /// 把字符串编码为合法的 JS 字符串字面量(含引号与转义)。 + private static func jsStringLiteral(_ s: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: [s]), + let arr = String(data: data, encoding: .utf8), + arr.count >= 2 else { return "\"\"" } + return String(arr.dropFirst().dropLast()) + } +} + +// MARK: - ContentView(入口:无域名 → 填写页;有域名 → 欢迎页/主界面) +struct ContentView: View { + @AppStorage(AppSettings.baseURLKey) private var baseURLString = "" + @State private var inApp = false + + var body: some View { + Group { + if inApp, let url = AppSettings.normalizedBaseURL(baseURLString) { + MainWebView(baseURL: url) + } else { + DomainSetupView(onEnter: { inApp = true }) + } + } + .onAppear { + // Dashboard = always-on display, prevent auto-lock + UIApplication.shared.isIdleTimerDisabled = true + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + } +} + +// MARK: - Splash +struct SplashView: View { + var body: some View { + ZStack { + Color(red: 15/255, green: 17/255, blue: 23/255).ignoresSafeArea() + VStack(spacing: 28) { + Text("Dashboard").font(.system(size: 30, weight: .semibold)).foregroundColor(.white) + if #available(iOS 15.0, *) { ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(1.3) } + else { UIActivity() } + Text("加载中...").font(.system(size: 14)).foregroundColor(.white.opacity(0.5)) + } + } + } +} + +struct UIActivity: UIViewRepresentable { + func makeUIView(context _: Context) -> UIActivityIndicatorView { + let v = UIActivityIndicatorView(style: .medium); v.color = .white; v.startAnimating(); return v + } + func updateUIView(_: UIActivityIndicatorView, context _: Context) {} +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0dbb89a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +# System dependencies +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y --no-install-recommends \ + git \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# Clone project +RUN git clone https://gitee.com/zhe-ovo/iot-dboard.git /app +WORKDIR /app + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 5000 + +CMD ["python", "start.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..349c4bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 喆OvO + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad21ad6 --- /dev/null +++ b/README.md @@ -0,0 +1,272 @@ +# Dashboard - 多人副屏系统 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +--- + +## 项目简介 + +多用户副屏系统,支持日历、天气、闹钟、番茄钟、倒计时等功能。每个用户拥有完全隔离的数据与独立的显示端/控制端: +- **显示端** (`/`) — 大屏/触屏设备展示信息 +- **控制端** (`/control`) — 完整配置面板 +- **Root 管理员** (`id=0`) — 首个自动创建的账户,拥有全部功能与用户管理权限 + +--- + +## 功能 + +- **多用户**:Root 管理员可注册/编辑/删除用户;所有用户数据完全隔离,普通用户无管理权限 +- **两种注册方式**:管理员代注册(正式账户,可代配 TOTP/Passkey);用户自助申请(管理员确认后生成临时账户) +- **临时账户**:服务器持有临时 TOTP 密钥,管理员仅可见实时验证码;用户登录后被强制引导配置自己的登录方式,配置完成即正式生效 +- **日历**:支持订阅 iCalendar (.ics) 链接及本地文件上传,自动同步日程 +- **天气**:基于和风天气 (QWeather) API,含逐小时/分钟级预报 +- **闹钟**:多组闹钟设定,自动弹出提醒,支持自定义铃声 +- **番茄钟**:定时提醒,自动继续,断网重连自动恢复 +- **倒计时**:可自定义时长,到时提醒 +- **自定义展示页**:支持多组自定义 HTML 模板,一键切换 + +--- + +## 🔐 安全特性 + +支持**双因素认证 (MFA)**,适合公网部署: + +| 方式 | 说明 | +|------|------| +| **Passkey (WebAuthn)** | 指纹、面容、安全密钥等设备内置生物/硬件认证 | +| **TOTP** | Google Authenticator / Microsoft Authenticator 等标准应用 | +| **一次性恢复码** | 8 个恢复码,每个仅可使用一次 | +| **临时密码** | 首次启动为 Root 自动生成 6 位随机密码,显示在控制台 | + +**登录流程**: + +``` +首次启动(无数据库)→ 自动创建 Root 管理员 (id=0),控制台打印临时密码 + ↓ +访问 /control → 输入 6 位数字(自动识别 TOTP / 临时账户 TOTP / Root 临时密码) + ↓ +Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA +正式用户 TOTP/Passkey → 直接进入控制面板 +临时账户 TOTP → 强制跳转 /mfa-setup 配置登录方式,配置完成后临时 TOTP 立即失效 +``` + +**用户管理(仅 Root)**: +- 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成) +- 用户自助申请:用户在 `/register-request` 填写信息 → 管理员确认 → 生成临时账户(服务器持有临时 TOTP 密钥,管理员仅看到实时验证码) +- 注册/编辑备注/删除用户均需**二次验证管理员身份**(Passkey 优先,无 Passkey 时用 TOTP) +- 删除用户会级联删除其全部数据并使其会话立即失效 +- 用户 ID 对用户透明(仅在备注默认为 ID 时可见一次),普通用户只能看到自己的数据 + +**安全防护**: +- 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时) +- CSRF 防护(Origin/Referer 校验) +- 敏感操作二次验证(用户管理、MFA 配置需重新确认身份) +- 会话 Cookie HttpOnly + SameSite=Lax + Secure(HTTPS 下) +- 会话版本按用户存储:删除用户 / "踢掉所有显示端" 立即失效 +- 内容安全策略(CSP)限制脚本/样式来源 +- 铃声上传仅允许音频格式+MIME 校验 +- 第三方日历数据 HTML 转义防止 XSS + +--- + +## 快速开始 + +### 开发环境 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 直接运行(调试模式) +python start.py + +# 3. 访问 +# 显示端: http://localhost:5000 +# 控制端: http://localhost:5000/control +``` + +### 生产部署 (推荐 Nginx + Certbot) + +``` +客户端 (HTTPS) + │ + ▼ 443 +Nginx (SSL termination + Certbot) + │ proxy_pass http://127.0.0.1:5000 + │ + ▼ 5000 (HTTP) +Flask (127.0.0.1:5000) + │ + ▼ +data.db (SQLite) +``` + +#### 1. Nginx 配置 + +```nginx +server { + listen 443 ssl; + server_name your-domain.com; + + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 可选:限制上传大小 + client_max_body_size 5M; +} + +# HTTP → HTTPS 强制跳转 +server { + listen 80; + server_name your-domain.com; + return 301 https://$host$request_uri; +} +``` + +#### 2. 启动 Flask + +```bash +# 生产模式启动 +FLASK_HTTPS=yes python start.py + +# 可选:固定 SECRET_KEY(保持跨重启会话有效) +export FLASK_SECRET_KEY="$(openssl rand -hex 32)" +``` + +#### 3. Certbot 申请证书 + +```bash +sudo certbot --nginx -d your-domain.com +``` + +--- + +--- + +## Docker 部署 + +### 快速启动 + +```bash +# 构建并启动(后台运行,首次会自动从 Gitee clone 代码) +docker compose up -d + +# 查看启动日志(获取临时密码) +docker compose logs app + +# 停止 +docker compose down +``` + +访问 `http://你的IP:5000`,日志中会打印临时密码。 + +### 更新代码 + +```bash +# 方法一:进入容器内 git pull(推荐) +docker compose exec app git pull +docker compose restart app + +# 方法二:重建镜像(较慢) +docker compose down +docker compose build --no-cache +docker compose up -d +``` + +### 环境变量配置 + +编辑 `docker-compose.yml` 的 `environment` 段: + +```yaml +environment: + - FLASK_BIND=0.0.0.0 + - FLASK_SECRET_KEY=your-secret-here # 可选:固定密钥 + - FLASK_HTTPS=yes # 如果前面有 HTTPS 反代 +``` + +### 持久化数据 + +- **数据库**:`./data/` 目录挂载到 `/app/data/`,数据库文件 `data.db` 自动在此创建 +- **项目代码**:命名卷 `app_code` 挂载到 `/app`,首次自动初始化为镜像内克隆的代码,之后 `git pull` 的修改永久保留数据库文件 `data.db` 会自动在该目录下创建,重启/升级后数据保留。 + +```bash +# 数据文件位置 +ls -la data/ +# data.db ← 数据库 +``` + +### 搭配 Nginx 反代(生产) + +使用宿主机上的 Nginx(或额外 Nginx 容器)做 SSL 终结。配置与前文 Nginx 部分相同,只需将 `proxy_pass` 指向 Docker 宿主机 IP:5000: + +```nginx +location / { + proxy_pass http://宿主机IP:5000; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; +} +``` + +> ⚠️ 注意:容器内 Flask 绑定 `0.0.0.0:5000`,宿主机可直接访问 `localhost:5000`。如需限制仅 Nginx 访问,在 `docker-compose.yml` 中将 `ports` 改为 `127.0.0.1:5000:5000`。 + +--- + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 Secure Cookie(Nginx 反代时启用) | +| `FLASK_SECRET_KEY` | 自动随机生成 | 固定密钥使会话跨重启保持有效 | +| `FLASK_BIND` | `127.0.0.1` | 监听地址,Docker 部署需设为 `0.0.0.0` | +| `FLASK_DB_PATH` | `./data.db` | 数据库文件路径,Docker 挂载数据卷时需同步修改 | +| `FLASK_DEBUG` | 空 | 设为 `true` 开启 Flask 调试模式(**生产环境禁用**) | + +--- + +## 文件结构 + +``` +├── run.py # 入口(端口 5001,仅开发) +├── start.py # 入口(端口 5000,推荐生产使用) +├── requirements.txt +├── backend/ +│ ├── app.py # Flask 应用工厂 + 安全配置 +│ ├── auth.py # MFA 认证(TOTP/Passkey/恢复码/临时密码) +│ ├── webauthn_mgr.py # WebAuthn (Passkey) 注册与认证 +│ ├── jwt_util.py # Ed25519 JWT 签名(和风天气) +│ ├── models.py # SQLite 数据库模型与迁移 +│ ├── routes.py # 全部 API 路由 + CSRF 防护 +│ └── *_mgr.py # 各功能模块管理器 +└── frontend/ + ├── templates/ # HTML 模板 + └── static/ + ├── css/ # 样式 + ├── js/ # JavaScript(纯原生,无框架) + └── ringtones/ # 自定义铃声 +``` + +--- + +### 数据库迁移(升级无需重写数据) + +启动时会自动检测并迁移数据库:旧版单用户数据库的数据自动归属 Root (id=0); +新增的字段一律带 DEFAULT 并在迁移时自动填充;`camera_config` 等已删除的表自动丢弃。 +旧版 4 位 PIN 不会被迁移。运行测试:`python3 tests/test_multi_user.py`。 + +## 注意 + +- **Passkey (WebAuthn) 需要 HTTPS**(localhost 除外) +- 旧版 4 位 PIN 已弃用,数据库迁移时旧 PIN 不会被迁移 + +### License +MIT diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/alarm_mgr.py b/backend/alarm_mgr.py new file mode 100644 index 0000000..85ebb1d --- /dev/null +++ b/backend/alarm_mgr.py @@ -0,0 +1,142 @@ +"""Alarm and clock management.""" +import os +from backend.time_util import now as tz_now +from flask import current_app +from backend.models import get_db + +# Per-user "already triggered today" state: {user_id: (date_str, set)} +_triggered_state: dict[int, tuple[str, set]] = {} + + + + +def _uid() -> int: + """Resolve the current user (request session or scheduler thread).""" + try: + from backend.auth import current_user_id + return current_user_id() or 0 + except Exception: + return 0 + + +def get_alarms(user_id: int = None) -> list: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + rows = [dict(r) for r in db.execute( + "SELECT * FROM alarms WHERE user_id=? ORDER BY time", (user_id,) + ).fetchall()] + db.close() + return rows + + +def add_alarm(label: str, time: str, days: str = "1,2,3,4,5,6,7", enabled: bool = True, + sound: str = "default", once: bool = False, user_id: int = None) -> dict: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + cur = db.execute( + "INSERT INTO alarms (user_id, label, time, days, enabled, sound, once) VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, label, time, days, 1 if enabled else 0, sound, 1 if once else 0) + ) + db.commit() + row = dict(db.execute("SELECT * FROM alarms WHERE id=?", (cur.lastrowid,)).fetchone()) + db.close() + return row + + +def update_alarm(alarm_id: int, label: str = None, time: str = None, + days: str = None, enabled: bool = None, sound: str = None, once: bool = None, + user_id: int = None): + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + fields = [] + vals = [] + for key, val in [("label", label), ("time", time), ("days", days), + ("enabled", 1 if enabled else 0 if enabled is not None else None), + ("sound", sound), + ("once", 1 if once else 0 if once is not None else None)]: + if val is not None: + fields.append(f"{key}=?") + vals.append(val) + ok = False + if fields: + vals.append(user_id) + vals.append(alarm_id) + cur = db.execute(f"UPDATE alarms SET {', '.join(fields)} WHERE user_id=? AND id=?", vals) + db.commit() + ok = cur.rowcount > 0 + db.close() + return ok + + +def delete_alarm(alarm_id: int, user_id: int = None) -> bool: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + cur = db.execute("DELETE FROM alarms WHERE user_id=? AND id=?", (user_id, alarm_id)) + db.commit() + db.close() + return cur.rowcount > 0 + + +def check_alarms(user_id: int = None) -> list: + if user_id is None: + user_id = _uid() + global _triggered_state + + db = get_db(current_app.config["DB_PATH"]) + now = tz_now() + today_str = now.strftime("%Y%m%d") + + triggered_date, triggered_today = _triggered_state.get(user_id, ("", set())) + if triggered_date != today_str: + triggered_today = set() + triggered_date = today_str + + current_time = now.strftime("%H:%M") + weekday = str(now.isoweekday()) + + rows = db.execute( + "SELECT * FROM alarms WHERE user_id=? AND enabled=1 AND time=?", + (user_id, current_time) + ).fetchall() + + triggered = [] + for r in rows: + days_list = r["days"].split(",") + if weekday in days_list: + alarm_id = r["id"] + if alarm_id in triggered_today: + continue + triggered_today.add(alarm_id) + triggered.append(dict(r)) + # Once-only alarms: auto-disable after trigger + if r["once"]: + db.execute("UPDATE alarms SET enabled=0 WHERE user_id=? AND id=?", (user_id, alarm_id)) + db.commit() + + _triggered_state[user_id] = (triggered_date, triggered_today) + db.close() + + if triggered: + current_app.logger.info(f"Triggering {len(triggered)} alarms: {[a['label'] for a in triggered]}") + current_app.event_bus.publish("alarm_triggered", { + "alarms": triggered, + "ts": now.isoformat(), + }, user_id=user_id) + return triggered + + +def get_ringtone_path(sound_name: str) -> str | None: + if not sound_name or sound_name == "default": + return None + ringtone_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "frontend", "static", "ringtones" + ) + path = os.path.join(ringtone_dir, sound_name) + if os.path.isfile(path): + return path + return None diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..9caad36 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,110 @@ +"""Flask application factory and SSE event system.""" +import json +import os +import queue +import secrets +import sqlite3 +import threading +from datetime import datetime, timedelta +from backend.time_util import now as tz_now, strptime as tz_strptime +from flask import Flask +from flask_cors import CORS +from werkzeug.middleware.proxy_fix import ProxyFix +from backend.models import init_db +from backend.routes import register_routes +from backend.events import EventBus +from backend.weather_mgr import fetch_weather, fetch_hourly + + +def create_app(db_path: str = "data.db") -> Flask: + app = Flask( + __name__, + template_folder="../frontend/templates", + static_folder="../frontend/static", + ) + # Generate a secure random key at startup. + # Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts). + app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) + # Defensive check: ensure key is never empty + if not app.config["SECRET_KEY"]: + app.config["SECRET_KEY"] = secrets.token_hex(32) + app.config["DB_PATH"] = db_path + # Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1) + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + + # Session hardening for production + https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes") + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["SESSION_COOKIE_SECURE"] = https + app.config["SESSION_PERMANENT"] = False + + # Strict CORS: only allow same-origin requests (no wildcard for cookie auth) + CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only + + # Content size limit: 5MB for uploads + app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 + + # Session lifetime: 24 hours idle timeout + app.config["PERMANENT_SESSION_LIFETIME"] = 86400 + + # Global event bus + app.event_bus = EventBus() + + init_db(db_path) + register_routes(app) + + # Security headers on every response + @app.after_request + def _security_headers(resp): + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["X-Frame-Options"] = "DENY" + resp.headers["Referrer-Policy"] = "same-origin" + # Strict CSP limits inline styles/scripts — relax if custom HTML used + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'" + ) + return resp + + # Startup weather fetch: only if cache is stale (>= 30 min) or empty + with app.app_context(): + _startup_weather(app, db_path) + + return app + + +def _startup_weather(app, db_path: str): + try: + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1" + ).fetchone() + conn.close() + except Exception: + row = None + + should_fetch = True + if row and row[0]: + try: + updated = tz_strptime(row[0], "%Y-%m-%d %H:%M:%S") + age = tz_now() - updated + if age < timedelta(minutes=30): + should_fetch = False + app.logger.info( + f"Startup: weather cache is {int(age.total_seconds()/60)} min old, skip fetch" + ) + except Exception: + pass + + if should_fetch: + try: + fetch_weather() + fetch_hourly() + app.logger.info("Startup: weather fetched (cache stale or empty)") + except Exception as e: + app.logger.warning(f"Startup weather fetch failed: {e}") diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..1fc92f9 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,446 @@ +"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password. + +Multi-user support: +- Sessions carry the authenticated `user_id` (Root = 0). +- The unified login (6-digit code) searches every active user's TOTP secret, + then temp accounts' temp-TOTP secrets, then Root's temp password. +- Session version is per-user (stored in the users table), so deleting a user + or kicking all sessions immediately invalidates their cookies. +""" +import functools +import secrets +import string +import time +from flask import session, redirect, url_for, request, jsonify, current_app, g +from backend.models import ( + get_auth_config, update_auth_config, get_temp_password, set_temp_password, + clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured, + get_user_session_version, user_exists, is_admin_user, get_db, +) + +# ─── Session keys ──────────────────────────── +SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated +SKEY_TEMP = "mfa_temp_pending" # bool: logged in with temp pw / temp account, config required +SKEY_VIEW = "mfa_view" # str: "control" or "display" +SKEY_REAUTH = "mfa_reauth_at" # float: timestamp of last re-authentication +SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation) +SKEY_USER = "user_id" # int: id of the authenticated user + + +def _db_path(preferred: str = None) -> str: + if preferred: + return preferred + return current_app.config.get("DB_PATH", "data.db") + + +def _validate_version(): + """If the session version is stale (kick_all / user deleted), clear the session.""" + uid = session.get(SKEY_USER) + if uid is None: + return + # Cache the validation result for the duration of this request. + if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid: + return + ver = session.get(SKEY_VERSION) + if ver is None: + return + stored = get_user_session_version(uid) + if stored == -1 or ver != stored: + clear_session() + return + g._sv_ok = True + g._sv_uid = uid + + +def is_authenticated() -> bool: + """Returns True if the user has completed MFA authentication.""" + _validate_version() + return session.get(SKEY_AUTH) is True + + +def is_temp_pending() -> bool: + """True if logged in with a temp password or temp account and must set up a login method.""" + _validate_version() + return session.get(SKEY_TEMP) is True + + +def current_user_id() -> int | None: + """Return the id of the current user (session user, or scheduler thread user).""" + try: + from flask import has_request_context + if has_request_context(): + _validate_version() + uid = session.get(SKEY_USER) + if uid is not None: + return int(uid) + except Exception: + pass + try: + from backend import time_util + return time_util.get_thread_user() + except Exception: + return None + + +def current_remark() -> str: + """Return the current user's remark for display (never the raw id unless it IS the remark).""" + uid = current_user_id() + if uid is None: + return "" + try: + from backend.models import get_user + u = get_user(uid) + return (u or {}).get("remark", "") or "" + except Exception: + return "" + + +def is_admin() -> bool: + """Only Root (id 0) — and any future admin role — can manage users.""" + uid = current_user_id() + if uid is None: + return False + return is_admin_user(uid) + + +def set_authenticated(view: str = "control", user_id: int = None): + if user_id is None: + user_id = session.get(SKEY_USER) or 0 + session[SKEY_AUTH] = True + session[SKEY_TEMP] = False + session[SKEY_VIEW] = view + session[SKEY_USER] = user_id + session[SKEY_VERSION] = get_user_session_version(user_id) + # A fresh authentication must not inherit the temp-setup re-auth marker. + session.pop(SKEY_REAUTH, None) + session.permanent = True + + +def set_temp_pending(view: str = "control", user_id: int = None): + """Mark the session as logged-in-but-must-configure (temp password / temp account).""" + if user_id is None: + user_id = session.get(SKEY_USER) or 0 + session[SKEY_AUTH] = False + session[SKEY_TEMP] = True + session[SKEY_VIEW] = view + session[SKEY_USER] = user_id + session[SKEY_VERSION] = get_user_session_version(user_id) + session.permanent = True + + +def clear_session(): + session.pop(SKEY_AUTH, None) + session.pop(SKEY_TEMP, None) + session.pop(SKEY_VIEW, None) + session.pop(SKEY_REAUTH, None) + session.pop(SKEY_USER, None) + session.pop(SKEY_VERSION, None) + + +# ─── Re-authentication guard ───────────────── +# Sensitive operations (user management, MFA changes) require re-confirming +# identity within the last N seconds. TOTP-first with a Passkey fallback +# (the fallback ceremony is handled by the routes layer). + +_REAUTH_TTL = 300 # 5 minutes + + +def require_reauth(): + """Check if recent re-authentication is still valid. + Skips the check for temp_pending users (first-time MFA setup flow). + """ + if is_temp_pending(): + mark_reauth() + return True + last = session.get(SKEY_REAUTH) + if last and (time.time() - last) < _REAUTH_TTL: + return True + return False + + +def mark_reauth(): + """Mark re-authentication as done now.""" + session[SKEY_REAUTH] = time.time() + + +# ─── Decorators ────────────────────────────── + +def _unauthorized(request): + if request.is_json or request.path.startswith("/api/"): + return jsonify({"error": "Unauthorized", "login_required": True}), 401 + view_param = session.get(SKEY_VIEW, "control") + return redirect(url_for("api.login_page", next=request.path, view=view_param)) + + +def login_required(view): + """Require full MFA authentication. Redirects to login if not authenticated.""" + @functools.wraps(view) + def wrapped(*args, **kwargs): + if is_authenticated(): + return view(*args, **kwargs) + # If logged in with temp password / temp account, redirect to MFA setup + if is_temp_pending(): + if request.is_json or request.path.startswith("/api/"): + return jsonify({"error": "MFA configuration required", "mfa_required": True}), 403 + return redirect(url_for("api.mfa_setup_page")) + return _unauthorized(request) + return wrapped + + +def login_or_temp_required(view): + """Allow access if fully authenticated OR in temp-pending state (MFA setup).""" + @functools.wraps(view) + def wrapped(*args, **kwargs): + if is_authenticated() or is_temp_pending(): + return view(*args, **kwargs) + return _unauthorized(request) + return wrapped + + +def display_login_required(view): + """Lighter auth check for display-end API calls.""" + @functools.wraps(view) + def wrapped(*args, **kwargs): + if is_authenticated(): + return view(*args, **kwargs) + return _unauthorized(request) + return wrapped + + +def admin_required(view): + """Require full authentication AND administrator (Root) role.""" + @functools.wraps(view) + def wrapped(*args, **kwargs): + if not is_authenticated(): + return _unauthorized(request) + if not is_admin(): + return jsonify({"error": "Forbidden: 仅管理员可执行此操作"}), 403 + return view(*args, **kwargs) + return wrapped + + +# ─── Unified Login ─────────────────────────── + +def verify_login(raw: str, db_path: str = None) -> dict | None: + """Unified login: tries every user's TOTP, then temp accounts' temp-TOTP, + then Root's temp password. + + Returns: + {'method': 'totp'|'temp_password', 'user_id': int, 'temp_account': bool} + or None on failure. + """ + if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6: + return None + db_path = db_path or current_app.config.get("DB_PATH", "data.db") + conn = get_db(db_path) + try: + import pyotp + # Load all users + their auth rows in ONE query (avoids per-user connections). + rows = conn.execute( + "SELECT u.id AS user_id, u.is_temp, u.temp_totp_secret, " + "a.totp_secret, a.totp_enabled, a.temp_password " + "FROM users u LEFT JOIN auth_config a ON a.user_id = u.id " + "WHERE u.status='active'" + ).fetchall() + + # 1. Formal users' TOTP secrets + for r in rows: + if r["totp_enabled"] and r["totp_secret"]: + if pyotp.TOTP(r["totp_secret"]).verify(raw, valid_window=1): + return {"method": "totp", "user_id": r["user_id"], "temp_account": False} + # 2. Temp accounts' temp-TOTP (server-only secret; admin sees only the live code) + for r in rows: + if r["is_temp"] and r["temp_totp_secret"]: + if pyotp.TOTP(r["temp_totp_secret"]).verify(raw, valid_window=1): + return {"method": "totp", "user_id": r["user_id"], "temp_account": True} + # 3. Root temp password (initial bootstrap) + root_row = next((r for r in rows if r["user_id"] == 0), None) + stored_pw = (root_row["temp_password"] or "") if root_row else "" + if stored_pw and secrets.compare_digest(raw, stored_pw): + return {"method": "temp_password", "user_id": 0, "temp_account": False} + return None + finally: + conn.close() + + +# ─── Temp Password ─────────────────────────── + +def generate_temp_password(length: int = 6) -> str: + """Generate a random numeric temp password.""" + return "".join(secrets.choice(string.digits) for _ in range(length)) + + +def verify_temp_password(raw: str, db_path: str = None, user_id: int = 0) -> bool: + """Verify a temporary password for a given user (only Root uses one).""" + if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6: + return False + db_path = db_path or current_app.config.get("DB_PATH", "data.db") + stored = get_temp_password(db_path, user_id) + if not stored: + return False + return secrets.compare_digest(raw, stored) + + +# ─── Rate Limiter ───────────────────────────── +# Progressive per-IP ban: 5err→5min, 10err→15min, 15+err→24hr + +_RATE_LIMIT_STORE: dict[str, dict] = {} # ip -> {"failures": int, "banned_until": float} + + +def _get_client_ip() -> str: + """Extract client IP for rate limiting. + Relies on ProxyFix + Nginx config to set remote_addr correctly. + """ + if not current_app: + return "unknown" + return request.remote_addr or "unknown" + + +def _ban_duration_sec(failures: int) -> int: + """Progressive ban duration based on consecutive failure count.""" + if failures < 5: + return 0 + if failures < 10: + return 5 * 60 # 5 minutes + if failures < 15: + return 15 * 60 # 15 minutes + return 24 * 60 * 60 # 24 hours + + +def check_rate_limit(ip: str = None) -> dict: + """Check if this IP is currently rate-limited.""" + if ip is None: + ip = _get_client_ip() + entry = _RATE_LIMIT_STORE.get(ip) + if not entry: + return {"ok": True} + + now = time.time() + banned_until = entry.get("banned_until", 0) + if now < banned_until: + remaining = int(banned_until - now) + if remaining < 60: + msg = f"登录尝试过多,请等待 {remaining} 秒" + elif remaining < 3600: + msg = f"登录尝试过多,请等待 {remaining // 60} 分钟" + else: + msg = f"登录尝试过多,请等待 {remaining // 3600} 小时" + return {"ok": False, "message": msg, "retry_after": remaining} + + # Ban expired — reset to current tier (keep failure count so next ban is higher tier) + entry["banned_until"] = 0 + return {"ok": True} + + +def record_failure(ip: str = None): + """Record a failed login attempt. Applies progressive ban if threshold reached.""" + if ip is None: + ip = _get_client_ip() + now = time.time() + entry = _RATE_LIMIT_STORE.get(ip, {"failures": 0, "banned_until": 0}) + entry["failures"] += 1 + + duration = _ban_duration_sec(entry["failures"]) + if duration > 0: + entry["banned_until"] = now + duration + if current_app: + current_app.logger.warning( + f"Rate limit: IP {ip} banned for {duration // 60}min " + f"after {entry['failures']} failures" + ) + + _RATE_LIMIT_STORE[ip] = entry + + +def record_success(ip: str = None): + """Reset failure counter on successful login.""" + if ip is None: + ip = _get_client_ip() + _RATE_LIMIT_STORE.pop(ip, None) + + +# ─── TOTP ──────────────────────────────────── + +def verify_totp(raw: str, db_path: str = None, user_id: int = 0) -> bool: + """Verify a TOTP 6-digit code against a specific user's stored secret.""" + if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6: + return False + db_path = db_path or current_app.config.get("DB_PATH", "data.db") + cfg = get_auth_config(db_path, user_id) + secret = cfg.get("totp_secret", "") + if not secret: + return False + import pyotp + totp = pyotp.TOTP(secret) + return totp.verify(raw, valid_window=1) + + +def current_totp_code(user_id: int, db_path: str = None) -> str: + """Compute the current live TOTP code for a user's secret (admin helper).""" + db_path = db_path or current_app.config.get("DB_PATH", "data.db") + cfg = get_auth_config(db_path, user_id) + secret = cfg.get("totp_secret", "") + if not secret: + return "" + import pyotp + return pyotp.TOTP(secret).now() + + +def current_temp_totp_code(user_id: int, db_path: str = None) -> str: + """Compute the current live temp-TOTP code for a temp account (admin helper).""" + db_path = db_path or current_app.config.get("DB_PATH", "data.db") + from backend.models import get_temp_totp_secret + secret = get_temp_totp_secret(user_id, db_path) + if not secret: + return "" + import pyotp + return pyotp.TOTP(secret).now() + + +# ─── Recovery Codes ────────────────────────── + +def generate_recovery_codes(count: int = 8) -> list: + """Generate N random alphanumeric recovery codes, each 10 chars.""" + alphabet = string.ascii_uppercase + string.digits + codes = [] + for _ in range(count): + code = "".join(secrets.choice(alphabet) for _ in range(10)) + formatted = f"{code[:4]}-{code[4:]}" + codes.append(formatted) + return codes + + +def verify_recovery_code(raw: str, db_path: str = None) -> int | None: + """Verify and consume a recovery code. Returns the user id on success, else None.""" + if not raw or not isinstance(raw, str): + return None + normalized = raw.replace("-", "").upper() + if len(normalized) != 10: + return None + + db_path = db_path or current_app.config.get("DB_PATH", "data.db") + conn = get_db(db_path) + try: + rows = conn.execute( + "SELECT u.id AS user_id, a.recovery_codes FROM users u " + "LEFT JOIN auth_config a ON a.user_id = u.id " + "WHERE u.status='active'" + ).fetchall() + for r in rows: + raw_codes = r["recovery_codes"] or "" + if not raw_codes: + continue + codes = raw_codes.split(",") + for i, stored in enumerate(codes): + stored_norm = stored.replace("-", "").upper() + if secrets.compare_digest(normalized, stored_norm): + codes.pop(i) + conn.execute( + "UPDATE auth_config SET recovery_codes=? WHERE user_id=?", + (",".join(codes), r["user_id"]) + ) + conn.commit() + return r["user_id"] + return None + finally: + conn.close() diff --git a/backend/calendar_mgr.py b/backend/calendar_mgr.py new file mode 100644 index 0000000..defb2c1 --- /dev/null +++ b/backend/calendar_mgr.py @@ -0,0 +1,471 @@ +"""Calendar management: ICS upload, subscription, groups, reminders. + +Every function is scoped to an explicit `user_id` so each user only ever +sees/mutates their own data (defense in depth: rows are filtered by user_id +even though they also join user-scoped groups/sources). +""" +import os +import hashlib +import tempfile +from datetime import datetime, timedelta, date +from backend.time_util import now as tz_now +from flask import current_app +from backend.models import get_db + + +def _uid() -> int: + """Resolve the current user for scheduler-thread calls.""" + try: + from backend.auth import current_user_id + return current_user_id() or 0 + except Exception: + return 0 + + +def _require_group_owner(db, user_id: int, group_id: int): + """Return the group row only if it belongs to the user (else None). + Prevents cross-user writes through unvalidated group ids.""" + return db.execute( + "SELECT id FROM calendar_groups WHERE id=? AND user_id=?", + (group_id, user_id) + ).fetchone() + + +def get_groups(user_id: int = None): + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + groups = [dict(r) for r in db.execute( + "SELECT * FROM calendar_groups WHERE user_id=? ORDER BY id", (user_id,) + ).fetchall()] + for g in groups: + g["sources"] = [dict(r) for r in db.execute( + "SELECT id, type, name, url, last_sync FROM calendar_sources " + "WHERE group_id=? AND user_id=?", (g["id"], user_id) + ).fetchall()] + db.close() + return groups + + +def create_group(name: str, color: str = "#4A90D9", user_id: int = None) -> dict: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + cur = db.execute( + "INSERT INTO calendar_groups (user_id, name, color) VALUES (?, ?, ?)", + (user_id, name, color) + ) + db.commit() + gid = cur.lastrowid + row = dict(db.execute("SELECT * FROM calendar_groups WHERE id=?", (gid,)).fetchone()) + db.close() + return row + + +def update_group(group_id: int, name: str = None, color: str = None, + enabled: bool = None, user_id: int = None): + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + fields = [] + vals = [] + if name is not None: + fields.append("name=?") + vals.append(name) + if color is not None: + fields.append("color=?") + vals.append(color) + if enabled is not None: + fields.append("enabled=?") + vals.append(1 if enabled else 0) + ok = False + if fields: + vals.append(user_id) + vals.append(group_id) + cur = db.execute(f"UPDATE calendar_groups SET {', '.join(fields)} WHERE user_id=? AND id=?", vals) + db.commit() + ok = cur.rowcount > 0 + db.close() + return ok + + +def delete_group(group_id: int, user_id: int = None) -> bool: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id IN " + "(SELECT id FROM calendar_sources WHERE user_id=? AND group_id=?)", + (user_id, user_id, group_id)) + db.execute("DELETE FROM calendar_sources WHERE user_id=? AND group_id=?", (user_id, group_id)) + db.execute("DELETE FROM reminders WHERE user_id=? AND calendar_group_id=?", (user_id, group_id)) + cur = db.execute("DELETE FROM calendar_groups WHERE user_id=? AND id=?", (user_id, group_id)) + db.commit() + db.close() + return cur.rowcount > 0 + + +def upload_ics(group_id: int, name: str, ics_content: str, user_id: int = None): + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + if not _require_group_owner(db, user_id, group_id): + db.close() + raise ValueError("分组不存在或无权访问") + cur = db.execute( + "INSERT INTO calendar_sources (user_id, group_id, type, name, ics_data) " + "VALUES (?, ?, 'upload', ?, ?)", + (user_id, group_id, name, ics_content) + ) + source_id = cur.lastrowid + db.commit() + _parse_and_store_events(db, user_id, source_id, ics_content) + db.close() + current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id) + + +def subscribe_ics(group_id: int, name: str, url: str, user_id: int = None): + if user_id is None: + user_id = _uid() + from backend.url_util import is_safe_public_url + if not is_safe_public_url(url): + raise ValueError("订阅地址无效或不安全(仅允许公网 http/https 地址)") + db = get_db(current_app.config["DB_PATH"]) + if not _require_group_owner(db, user_id, group_id): + db.close() + raise ValueError("分组不存在或无权访问") + cur = db.execute( + "INSERT INTO calendar_sources (user_id, group_id, type, name, url) " + "VALUES (?, ?, 'subscription', ?, ?)", + (user_id, group_id, name, url) + ) + source_id = cur.lastrowid + db.commit() + db.close() + sync_subscription(source_id, user_id) + + +def sync_subscription(source_id: int, user_id: int = None): + """Pull and parse a remote ICS subscription.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute("SELECT * FROM calendar_sources WHERE id=? AND user_id=?", + (source_id, user_id)).fetchone() + if not row or row["type"] != "subscription" or not row["url"]: + db.close() + return + try: + from backend.url_util import safe_http_get + resp = safe_http_get(row["url"], timeout=15) + if resp is None: + current_app.logger.warning(f"Sync skipped: unsafe URL for source {source_id}") + return + ics_data = resp.text + # Clear old events for this source + db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", + (user_id, source_id)) + _parse_and_store_events(db, user_id, source_id, ics_data) + db.execute("UPDATE calendar_sources SET last_sync=datetime('now') WHERE id=? AND user_id=?", + (source_id, user_id)) + db.commit() + current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id) + except Exception as e: + current_app.logger.error(f"Sync failed for source {source_id}: {e}") + finally: + db.close() + + +def sync_all_subscriptions(user_id: int = None): + """Sync all subscription sources for one user (or all users when user_id is None).""" + if user_id is None: + db = get_db(current_app.config["DB_PATH"]) + rows = db.execute("SELECT user_id, id FROM calendar_sources WHERE type='subscription' AND url IS NOT NULL").fetchall() + db.close() + for r in rows: + sync_subscription(r["id"], r["user_id"]) + return + db = get_db(current_app.config["DB_PATH"]) + sources = db.execute( + "SELECT id FROM calendar_sources WHERE user_id=? AND type='subscription' AND url IS NOT NULL", + (user_id,) + ).fetchall() + db.close() + for s in sources: + sync_subscription(s["id"], user_id) + + +def delete_source(source_id: int, user_id: int = None) -> bool: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", (user_id, source_id)) + cur = db.execute("DELETE FROM calendar_sources WHERE user_id=? AND id=?", (user_id, source_id)) + db.commit() + db.close() + return cur.rowcount > 0 + + +def get_events(group_ids: list = None, days: int = 7, user_id: int = None): + """Get events for enabled groups, limited to N days ahead.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + now = tz_now().strftime("%Y%m%dT000000") + end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959") + + if group_ids: + placeholders = ','.join('?' * len(group_ids)) + rows = db.execute(f""" + SELECT e.*, s.group_id FROM calendar_events e + JOIN calendar_sources s ON e.source_id = s.id + JOIN calendar_groups g ON s.group_id = g.id + WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 + AND s.group_id IN ({placeholders}) + AND e.dtend >= ? AND e.dtstart <= ? + ORDER BY e.dtstart + """, (user_id, user_id, *group_ids, now, end)).fetchall() + else: + rows = db.execute(""" + SELECT e.*, s.group_id FROM calendar_events e + JOIN calendar_sources s ON e.source_id = s.id + JOIN calendar_groups g ON s.group_id = g.id + WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 + AND e.dtend >= ? AND e.dtstart <= ? + ORDER BY e.dtstart + """, (user_id, user_id, now, end)).fetchall() + + db.close() + return [dict(r) for r in rows] + + +def get_upcoming_reminders(user_id: int = None): + """Check for calendar events whose reminders are due.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + now = tz_now() + rows = db.execute(""" + SELECT e.*, r.minutes_before, g.name as group_name FROM calendar_events e + JOIN calendar_sources s ON e.source_id = s.id + JOIN calendar_groups g ON s.group_id = g.id + JOIN reminders r ON r.calendar_group_id = g.id + WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND r.enabled=1 + """, (user_id, user_id)).fetchall() + db.close() + + due = [] + for r in rows: + try: + dt = datetime.strptime(r["dtstart"], "%Y%m%dT%H%M%S") + remind_at = dt - timedelta(minutes=r["minutes_before"]) + if remind_at <= now < dt: + due.append(dict(r)) + except (ValueError, TypeError): + pass + return due + + +def set_reminder(group_id: int, minutes_before: int, enabled: bool = True, user_id: int = None): + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + if not _require_group_owner(db, user_id, group_id): + db.close() + raise ValueError("分组不存在或无权访问") + existing = db.execute("SELECT id FROM reminders WHERE user_id=? AND calendar_group_id=?", + (user_id, group_id)).fetchone() + if existing: + db.execute("UPDATE reminders SET minutes_before=?, enabled=? WHERE user_id=? AND calendar_group_id=?", + (minutes_before, 1 if enabled else 0, user_id, group_id)) + else: + db.execute("INSERT INTO reminders (user_id, calendar_group_id, minutes_before, enabled) " + "VALUES (?, ?, ?, ?)", + (user_id, group_id, minutes_before, 1 if enabled else 0)) + db.commit() + db.close() + + +def get_or_create_manual_source(group_id: int, user_id: int = None) -> int: + """Get or create a 'manual' source for the group.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute( + "SELECT id FROM calendar_sources WHERE user_id=? AND group_id=? AND type='manual'", + (user_id, group_id) + ).fetchone() + if row: + db.close() + return row["id"] + cur = db.execute( + "INSERT INTO calendar_sources (user_id, group_id, type, name) VALUES (?, ?, 'manual', '手动添加')", + (user_id, group_id) + ) + db.commit() + sid = cur.lastrowid + db.close() + return sid + + +def _normalize_dt(dt_str: str) -> str: + """Ensure date string has the 'T' separator if it looks like YYYYMMDDHHMMSS.""" + if not dt_str or 'T' in dt_str: + return dt_str + if len(dt_str) >= 14: + return dt_str[:8] + 'T' + dt_str[8:] + return dt_str + + +def add_event(group_id: int, summary: str, dtstart: str, dtend: str = None, + description: str = "", location: str = "", is_all_day: bool = False, + user_id: int = None) -> dict: + """Manually add a single event to a group.""" + if user_id is None: + user_id = _uid() + dtstart = _normalize_dt(dtstart) + dtend = _normalize_dt(dtend) + db = get_db(current_app.config["DB_PATH"]) + if not _require_group_owner(db, user_id, group_id): + db.close() + raise ValueError("分组不存在或无权访问") + source_id = get_or_create_manual_source(group_id, user_id) + if dtend is None: + dtend = dtstart + cur = db.execute(""" + INSERT INTO calendar_events (user_id, source_id, summary, dtstart, dtend, description, location, is_all_day) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (user_id, source_id, summary, dtstart, dtend, description, location, 1 if is_all_day else 0)) + db.commit() + eid = cur.lastrowid + row = dict(db.execute("SELECT * FROM calendar_events WHERE id=?", (eid,)).fetchone()) + db.close() + current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id) + return row + + +def get_events_by_group(group_id: int, days: int = 30, user_id: int = None) -> list: + """Get all events for a specific group.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + now = tz_now().strftime("%Y%m%dT000000") + end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959") + rows = db.execute(""" + SELECT e.*, s.group_id FROM calendar_events e + JOIN calendar_sources s ON e.source_id = s.id + WHERE e.user_id=? AND s.user_id=? AND s.group_id=? AND e.dtend >= ? AND e.dtstart <= ? + ORDER BY e.dtstart + """, (user_id, user_id, group_id, now, end)).fetchall() + db.close() + return [dict(r) for r in rows] + + +def update_event(event_id: int, summary: str = None, dtstart: str = None, + dtend: str = None, description: str = None, location: str = None, + user_id: int = None) -> bool: + if user_id is None: + user_id = _uid() + dtstart = _normalize_dt(dtstart) + dtend = _normalize_dt(dtend) + db = get_db(current_app.config["DB_PATH"]) + fields = [] + vals = [] + for key, val in [("summary", summary), ("dtstart", dtstart), ("dtend", dtend), + ("description", description), ("location", location)]: + if val is not None: + fields.append(f"{key}=?") + vals.append(val) + if not fields: + db.close() + return False + vals.append(user_id) + vals.append(event_id) + cur = db.execute(f"UPDATE calendar_events SET {', '.join(fields)} WHERE user_id=? AND id=?", vals) + db.commit() + ok = cur.rowcount > 0 + # Get group_id for event broadcast + row = db.execute(""" + SELECT s.group_id FROM calendar_events e + JOIN calendar_sources s ON e.source_id = s.id + WHERE e.user_id=? AND e.id=? + """, (user_id, event_id)).fetchone() + db.close() + if ok and row: + current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id) + return ok + + +def delete_event(event_id: int, user_id: int = None) -> bool: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute(""" + SELECT s.group_id FROM calendar_events e + JOIN calendar_sources s ON e.source_id = s.id + WHERE e.user_id=? AND e.id=? + """, (user_id, event_id)).fetchone() + cur = db.execute("DELETE FROM calendar_events WHERE user_id=? AND id=?", (user_id, event_id)) + db.commit() + db.close() + if cur.rowcount > 0 and row: + current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id) + return cur.rowcount > 0 + + +def _parse_and_store_events(db, user_id: int, source_id: int, ics_data: str): + """Parse ICS content and store events in DB, expanding recurring events.""" + from icalendar import Calendar + import recurring_ical_events + try: + cal = Calendar.from_ical(ics_data) + except Exception: + return + + # Determine the expansion window: from start of today up to 365 days ahead + today = tz_now().date() + window_start = datetime(today.year, today.month, today.day) + window_end = window_start + timedelta(days=365) + + try: + expanded = recurring_ical_events.of(cal).between(window_start, window_end) + except Exception: + expanded = [] + + for comp in expanded: + if comp.name != "VEVENT": + continue + uid = str(comp.get("uid", "")) + summary = str(comp.get("summary", "")) + description = str(comp.get("description", "")) + location = str(comp.get("location", "")) + + dtstart = comp.get("dtstart") + dtend = comp.get("dtend") + + if dtstart is None: + continue + + dtstart_str = _format_dt(dtstart.dt) + if dtend: + dtend_str = _format_dt(dtend.dt) + else: + dtend_str = dtstart_str + + is_all_day = 1 if (hasattr(dtstart.dt, 'date') and not hasattr(dtstart.dt, 'time')) else 0 + + db.execute(""" + INSERT INTO calendar_events (user_id, source_id, uid, summary, description, location, dtstart, dtend, is_all_day) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user_id, source_id, uid, summary, description, location, dtstart_str, dtend_str, is_all_day)) + + db.commit() + + +def _format_dt(dt_val) -> str: + """Format a datetime/date to the 'YYYYMMDDTHHMMSS' string.""" + from datetime import date, datetime as dt_cls + if isinstance(dt_val, dt_cls): + return dt_val.strftime("%Y%m%dT%H%M%S") + elif isinstance(dt_val, date): + return dt_val.strftime("%Y%m%dT000000") + return str(dt_val) diff --git a/backend/countdown_mgr.py b/backend/countdown_mgr.py new file mode 100644 index 0000000..95f8586 --- /dev/null +++ b/backend/countdown_mgr.py @@ -0,0 +1,119 @@ +"""Countdown timer management — duration-based, once-only.""" +import time +from datetime import datetime, timedelta +from backend.time_util import isoformat as tz_isoformat +from flask import current_app +from backend.models import get_db + + +def _uid() -> int: + """Resolve the current user (request session or scheduler thread).""" + try: + from backend.auth import current_user_id + return current_user_id() or 0 + except Exception: + return 0 + + +def get_countdowns(user_id: int = None) -> list: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + rows = [dict(r) for r in db.execute( + "SELECT * FROM countdowns WHERE user_id=? ORDER BY id DESC", (user_id,) + ).fetchall()] + db.close() + return rows + + +def add_countdown(label: str, duration_seconds: int, + enabled: bool = True, sound: str = "default", user_id: int = None) -> dict: + if user_id is None: + user_id = _uid() + end_ts = str(time.time() + duration_seconds) + + db = get_db(current_app.config["DB_PATH"]) + cur = db.execute( + "INSERT INTO countdowns (user_id, label, duration_seconds, end_time, enabled, sound) " + "VALUES (?, ?, ?, ?, ?, ?)", + (user_id, label, duration_seconds, end_ts, 1 if enabled else 0, sound) + ) + db.commit() + row = dict(db.execute("SELECT * FROM countdowns WHERE id=?", (cur.lastrowid,)).fetchone()) + db.close() + return row + + +def update_countdown(cd_id: int, label: str = None, duration_seconds: int = None, + enabled: bool = None, sound: str = None, user_id: int = None): + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + fields = [] + vals = [] + + if duration_seconds is not None: + fields.append("duration_seconds=?") + vals.append(duration_seconds) + fields.append("end_time=?") + vals.append(str(time.time() + duration_seconds)) + + for key, val in [("label", label), + ("enabled", 1 if enabled else 0 if enabled is not None else None), + ("sound", sound)]: + if val is not None: + fields.append(f"{key}=?") + vals.append(val) + + ok = False + if fields: + vals.append(user_id) + vals.append(cd_id) + cur = db.execute(f"UPDATE countdowns SET {', '.join(fields)} WHERE user_id=? AND id=?", vals) + db.commit() + ok = cur.rowcount > 0 + db.close() + return ok + + +def delete_countdown(cd_id: int, user_id: int = None) -> bool: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + cur = db.execute("DELETE FROM countdowns WHERE user_id=? AND id=?", (user_id, cd_id)) + db.commit() + db.close() + return cur.rowcount > 0 + + +def check_countdowns(user_id: int = None) -> list: + """Check for countdowns whose end_time has passed. Auto-disable after trigger.""" + if user_id is None: + user_id = _uid() + now_ts = str(time.time()) + + db = get_db(current_app.config["DB_PATH"]) + rows = db.execute( + "SELECT * FROM countdowns WHERE user_id=? AND enabled=1 AND end_time <= ? AND end_time != ''", + (user_id, now_ts) + ).fetchall() + + triggered = [] + for r in rows: + cd_id = r["id"] + triggered.append(dict(r)) + db.execute("UPDATE countdowns SET enabled=0 WHERE user_id=? AND id=?", (user_id, cd_id)) + db.commit() + + db.close() + + if triggered: + current_app.logger.info( + f"Countdowns triggered: {[c['label'] for c in triggered]}" + ) + current_app.event_bus.publish("countdown_triggered", { + "countdowns": triggered, + "ts": tz_isoformat(), + }, user_id=user_id) + + return triggered diff --git a/backend/events.py b/backend/events.py new file mode 100644 index 0000000..5083061 --- /dev/null +++ b/backend/events.py @@ -0,0 +1,49 @@ +"""Server-Sent Event bus for pushing updates to displays. + +Events are scoped by user_id so that each user's display only receives +updates about their own data (alarms, calendar, pomodoro, ...). +""" +import json +import queue +import threading + + +class EventBus: + """Simple pub/sub event system with SSE support. + + publish(event_type, data, user_id=None): when user_id is given, only + subscribers registered for that user receive the event. + """ + + def __init__(self): + self._subscribers: list[tuple[int | None, queue.Queue]] = [] + self._lock = threading.Lock() + + def subscribe(self, user_id: int | None = None) -> queue.Queue: + """Subscribe to events. user_id=None receives ALL events (broadcast).""" + q: queue.Queue = queue.Queue() + with self._lock: + self._subscribers.append((user_id, q)) + return q + + def unsubscribe(self, q: queue.Queue): + with self._lock: + self._subscribers = [(uid, sq) for uid, sq in self._subscribers if sq is not q] + + def publish(self, event_type: str, data: dict, user_id: int | None = None): + msg = json.dumps({"type": event_type, "data": data}) + with self._lock: + dead = [] + for sub_uid, q in self._subscribers: + if sub_uid is not None and user_id is not None and sub_uid != user_id: + continue + try: + q.put_nowait(msg) + except queue.Full: + dead.append((sub_uid, q)) + for item in dead: + self._subscribers.remove(item) + + def broadcast(self): + """Send heartbeat to all subscribers.""" + self.publish("heartbeat", {"ts": __import__("time").time()}) diff --git a/backend/jwt_util.py b/backend/jwt_util.py new file mode 100644 index 0000000..193d10c --- /dev/null +++ b/backend/jwt_util.py @@ -0,0 +1,55 @@ +"""Ed25519 key pair generation and JWT signing for QWeather API.""" +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives import serialization +import jwt +import time + + +def generate_key_pair() -> tuple: + """Generate an Ed25519 key pair. + + Returns: + (private_key_pem: str, public_key_pem: str) + """ + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + + return private_pem, public_pem + + +def sign_jwt(private_key_pem: str, credential_id: str, project_id: str, + expiry_seconds: int = 3600) -> str: + """Sign a JWT for QWeather API authentication. + + Args: + private_key_pem: Ed25519 private key in PEM format. + credential_id: QWeather credential ID (kid). + project_id: QWeather project ID (sub). + expiry_seconds: Token validity in seconds (max 86400 per QWeather). + + Returns: + Signed JWT string. + """ + now = int(time.time()) + payload = { + "sub": project_id, + "iat": now, + "exp": now + min(expiry_seconds, 86400), + } + headers = { + "alg": "EdDSA", + "kid": credential_id, + } + token = jwt.encode(payload, private_key_pem, algorithm="EdDSA", headers=headers) + return token diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..b3cbaa6 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,774 @@ +"""SQLite database models, initialization, and user-scoped helpers. + +Multi-user design: +- The `users` table stores every account. Root (ID 0) is the administrator and + is created automatically on first boot (fresh DB) or when migrating an + existing single-user database (all pre-existing data becomes Root's data). +- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column, + so legacy single-user rows are automatically attributed to Root on migration + and any NEW column added in the future gets a DEFAULT (the migration fills + missing columns with their SCHEMA defaults). +- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0). +""" +import sqlite3 +import os +import sys + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + remark TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')), + is_temp INTEGER NOT NULL DEFAULT 0, + temp_totp_secret TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')), + session_version INTEGER NOT NULL DEFAULT 0, + theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')), + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS auth_config ( + user_id INTEGER PRIMARY KEY DEFAULT 0, + passkey_credential_id TEXT DEFAULT '', + passkey_public_key TEXT DEFAULT '', + totp_secret TEXT DEFAULT '', + totp_enabled INTEGER DEFAULT 0, + recovery_codes TEXT DEFAULT '', + temp_password TEXT DEFAULT '', + must_configure_mfa INTEGER DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS calendar_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + color TEXT DEFAULT '#4A90D9', + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS calendar_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + group_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')), + name TEXT NOT NULL, + url TEXT, + ics_data TEXT, + last_sync TEXT, + FOREIGN KEY (group_id) REFERENCES calendar_groups(id) +); + +CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + source_id INTEGER NOT NULL, + uid TEXT, + summary TEXT, + description TEXT, + location TEXT, + dtstart TEXT, + dtend TEXT, + is_all_day INTEGER DEFAULT 0, + FOREIGN KEY (source_id) REFERENCES calendar_sources(id) +); + +CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + calendar_group_id INTEGER NOT NULL, + minutes_before INTEGER DEFAULT 15, + enabled INTEGER DEFAULT 1, + FOREIGN KEY (calendar_group_id) REFERENCES calendar_groups(id) +); + +CREATE TABLE IF NOT EXISTS weather_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + api_key TEXT DEFAULT '', + city TEXT DEFAULT 'Beijing', + lat REAL, + lon REAL, + units TEXT DEFAULT 'metric', + update_interval INTEGER DEFAULT 1800, + jwt_private_key TEXT DEFAULT '', + jwt_public_key TEXT DEFAULT '', + credential_id TEXT DEFAULT '', + project_id TEXT DEFAULT '', + qweather_host TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS weather_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + data TEXT, + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS weather_hourly_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + data TEXT, + fetched_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS weather_minutely_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + data TEXT, + fetched_at TEXT +); + +CREATE TABLE IF NOT EXISTS weather_warning_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + data TEXT, + fetched_at TEXT +); + +CREATE TABLE IF NOT EXISTS clock_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + city TEXT DEFAULT 'Beijing', + timezone TEXT DEFAULT 'Asia/Shanghai', + is_primary INTEGER DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS alarms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + label TEXT DEFAULT '', + time TEXT NOT NULL, + days TEXT DEFAULT '1,2,3,4,5,6,7', + enabled INTEGER DEFAULT 1, + sound TEXT DEFAULT 'default', + once INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS countdowns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + label TEXT DEFAULT '', + duration_seconds INTEGER DEFAULT 300, + end_time TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + sound TEXT DEFAULT 'default' +); + +CREATE TABLE IF NOT EXISTS display_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')), + custom_html TEXT DEFAULT '', + custom_html_1 TEXT DEFAULT '', + custom_html_2 TEXT DEFAULT '', + custom_html_3 TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS pomodoro_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + running INTEGER DEFAULT 0, + phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')), + work_minutes INTEGER DEFAULT 25, + break_minutes INTEGER DEFAULT 5, + long_break_minutes INTEGER DEFAULT 15, + cycles_before_long_break INTEGER DEFAULT 4, + end_time TEXT, + remaining_seconds INTEGER DEFAULT 1500, + cycles INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 0, + display_controls_enabled INTEGER DEFAULT 1, + cycles_date TEXT DEFAULT '', + work_label TEXT DEFAULT '工作中', + break_label TEXT DEFAULT '休息中' +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user_id); +""" + + +# Tables whose rows are completely per-user (used for cascade delete). +USER_DATA_TABLES = [ + "auth_config", "calendar_groups", "calendar_sources", "calendar_events", + "reminders", "weather_config", "weather_cache", "weather_hourly_cache", + "weather_minutely_cache", "weather_warning_cache", "clock_config", + "alarms", "countdowns", "display_config", "pomodoro_state", +] + + +def get_db(db_path: str = "data.db") -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def _db_path(preferred: str = None) -> str: + """Get the actual database path, preferring current_app config if available.""" + if preferred: + return preferred + try: + from flask import current_app + return current_app.config.get("DB_PATH", "data.db") + except Exception: + return "data.db" + + +# ─── Users ─────────────────────────────────── + +def get_users(db_path: str = None) -> list: + """Return all users ordered by id.""" + conn = get_db(_db_path(db_path)) + rows = [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall()] + conn.close() + return rows + + +def get_active_user_ids(db_path: str = None) -> list: + """Return ids of all active (non-pending) users. Used by the scheduler.""" + conn = get_db(_db_path(db_path)) + rows = conn.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall() + conn.close() + return [r["id"] for r in rows] + + +def get_user(user_id: int, db_path: str = None) -> dict | None: + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return dict(row) if row else None + + +def user_exists(user_id: int, db_path: str = None) -> bool: + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT 1 FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return row is not None + + +def is_admin_user(user_id: int, db_path: str = None) -> bool: + """Root is always admin; otherwise check the role column.""" + if user_id == 0: + return True + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT role FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return bool(row and row["role"] == "admin") + + +def create_user(remark: str = "", role: str = "user", is_temp: int = 0, + temp_totp_secret: str = "", status: str = "active", + db_path: str = None) -> int: + """Create a user. Returns the new user id. + The remark defaults to the user's own id (the only place an id is shown). + """ + conn = get_db(_db_path(db_path)) + cur = conn.execute( + "INSERT INTO users (remark, role, is_temp, temp_totp_secret, status) VALUES (?, ?, ?, ?, ?)", + (remark, role, 1 if is_temp else 0, temp_totp_secret, status) + ) + uid = cur.lastrowid + if not remark: + conn.execute("UPDATE users SET remark=? WHERE id=?", (str(uid), uid)) + conn.commit() + conn.close() + return uid + + +def update_user(user_id: int, remark: str = None, role: str = None, + is_temp: int = None, temp_totp_secret: str = None, + status: str = None, theme: str = None, db_path: str = None): + conn = get_db(_db_path(db_path)) + fields = [] + vals = [] + for key, val in [("remark", remark), ("role", role), ("is_temp", is_temp), + ("temp_totp_secret", temp_totp_secret), ("status", status), + ("theme", theme)]: + if val is not None: + fields.append(f"{key}=?") + vals.append(val) + if fields: + vals.append(user_id) + conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id=?", vals) + conn.commit() + conn.close() + + +def delete_user(user_id: int, db_path: str = None): + """Delete a user and ALL of their data. Root (id 0) cannot be deleted. + + Deletion order respects foreign keys (children before parents). + """ + if user_id == 0: + return + conn = get_db(_db_path(db_path)) + # Children first so FK constraints never reject a delete. + ordered = [ + "calendar_events", "calendar_sources", "reminders", "calendar_groups", + "weather_cache", "weather_hourly_cache", "weather_minutely_cache", + "weather_warning_cache", "weather_config", "clock_config", + "alarms", "countdowns", "display_config", "pomodoro_state", "auth_config", + ] + try: + for table in ordered: + conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,)) + conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + conn.commit() + finally: + conn.close() + + +def get_user_session_version(user_id: int, db_path: str = None) -> int: + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return row["session_version"] if row else -1 + + +def bump_user_session_version(user_id: int, db_path: str = None) -> int: + """Invalidate all sessions of one user by incrementing their version.""" + conn = get_db(_db_path(db_path)) + conn.execute("UPDATE users SET session_version = session_version + 1 WHERE id=?", (user_id,)) + conn.commit() + row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return row["session_version"] if row else -1 + + +def bump_all_session_versions(db_path: str = None) -> int: + """Invalidate every session (admin 'kick all' action).""" + conn = get_db(_db_path(db_path)) + conn.execute("UPDATE users SET session_version = session_version + 1") + conn.commit() + conn.close() + return 0 + + +def is_temp_account(user_id: int, db_path: str = None) -> bool: + """True if the account is a temp account (awaiting first login-method setup).""" + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT is_temp FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return bool(row and row["is_temp"]) + + +def get_temp_totp_secret(user_id: int, db_path: str = None) -> str: + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT temp_totp_secret FROM users WHERE id=?", (user_id,)).fetchone() + conn.close() + return (row["temp_totp_secret"] or "") if row else "" + + +def finalize_temp_account(user_id: int, db_path: str = None): + """Convert a temp account into a formal account: + invalidates the temp TOTP and clears the temp flag. + """ + conn = get_db(_db_path(db_path)) + conn.execute( + "UPDATE users SET is_temp=0, temp_totp_secret='' WHERE id=?", + (user_id,) + ) + conn.execute( + "UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?", + (user_id,) + ) + conn.commit() + conn.close() + + +# ─── Display config (per user) ────────────── + +def _ensure_display_row(conn, user_id: int): + row = conn.execute("SELECT id FROM display_config WHERE user_id=?", (user_id,)).fetchone() + if row is None: + conn.execute( + "INSERT INTO display_config (user_id, mode) VALUES (?, 'default')", + (user_id,) + ) + + +def get_display_config(db_path: str = None, user_id: int = 0) -> dict: + db = get_db(_db_path(db_path)) + row = db.execute( + "SELECT mode, custom_html, custom_html_1, custom_html_2, custom_html_3 " + "FROM display_config WHERE user_id=?", + (user_id,) + ).fetchone() + if row is None: + db.execute( + "INSERT INTO display_config (user_id, mode, custom_html, custom_html_1, custom_html_2, custom_html_3) " + "VALUES (?, 'default', '', '', '', '')", + (user_id,) + ) + db.commit() + cfg = {"mode": "default", "custom_html": "", "custom_html_1": "", "custom_html_2": "", "custom_html_3": ""} + else: + cfg = { + "mode": row["mode"], + "custom_html": row["custom_html"] or "", + "custom_html_1": row["custom_html_1"] or "", + "custom_html_2": row["custom_html_2"] or "", + "custom_html_3": row["custom_html_3"] or "", + } + db.close() + return cfg + + +def set_display_mode(mode: str, db_path: str = None, user_id: int = 0) -> bool: + if mode not in ("default", "custom"): + return False + db = get_db(_db_path(db_path)) + _ensure_display_row(db, user_id) + db.execute("UPDATE display_config SET mode=? WHERE user_id=?", (mode, user_id)) + db.commit() + db.close() + return True + + +def set_custom_html(html: str, db_path: str = None, user_id: int = 0) -> bool: + db = get_db(_db_path(db_path)) + _ensure_display_row(db, user_id) + db.execute("UPDATE display_config SET custom_html=? WHERE user_id=?", (html, user_id)) + db.commit() + db.close() + return True + + +def set_custom_html_template(index: int, html: str, db_path: str = None, user_id: int = 0) -> bool: + if index not in (1, 2, 3): + return False + db = get_db(_db_path(db_path)) + _ensure_display_row(db, user_id) + db.execute( + f"UPDATE display_config SET custom_html_{index}=? WHERE user_id=?", + (html, user_id) + ) + db.commit() + db.close() + return True + + +def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool: + """Copy template N (0=main custom_html, 1-3=custom_html_1-3) into custom_html and set mode to custom.""" + db = get_db(_db_path(db_path)) + row = db.execute( + "SELECT custom_html, custom_html_1, custom_html_2, custom_html_3 " + "FROM display_config WHERE user_id=?", + (user_id,) + ).fetchone() + if row is None: + db.close() + return False + if index == 0: + html = row["custom_html"] or "" + elif index == 1: + html = row["custom_html_1"] or "" + elif index == 2: + html = row["custom_html_2"] or "" + elif index == 3: + html = row["custom_html_3"] or "" + else: + db.close() + return False + db.execute("UPDATE display_config SET mode='custom', custom_html=? WHERE user_id=?", (html, user_id)) + db.commit() + db.close() + return True + + +# ─── Auth / MFA helpers (per user) ────────── + +def get_auth_config(db_path: str = None, user_id: int = 0) -> dict: + """Return the full auth_config row for a user, with defaults for missing fields.""" + conn = get_db(_db_path(db_path)) + row = conn.execute("SELECT * FROM auth_config WHERE user_id = ?", (user_id,)).fetchone() + if row is None: + conn.execute( + """INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key, + totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa) + VALUES (?, '', '', '', 0, '', '', 1)""", + (user_id,) + ) + conn.commit() + cfg = { + "user_id": user_id, + "passkey_credential_id": "", + "passkey_public_key": "", + "totp_secret": "", + "totp_enabled": 0, + "recovery_codes": "", + "temp_password": "", + "must_configure_mfa": 1, + } + else: + cfg = { + "user_id": row["user_id"], + "passkey_credential_id": row["passkey_credential_id"] or "", + "passkey_public_key": row["passkey_public_key"] or "", + "totp_secret": row["totp_secret"] or "", + "totp_enabled": bool(row["totp_enabled"]), + "recovery_codes": row["recovery_codes"] or "", + "temp_password": row["temp_password"] or "", + "must_configure_mfa": bool(row["must_configure_mfa"]), + } + conn.close() + return cfg + + +def update_auth_config(user_id: int = 0, **kwargs): + """Update auth_config fields. Keys match DB column names.""" + db_path = _db_path(kwargs.pop("_db_path", None)) + conn = get_db(db_path) + row = conn.execute("SELECT user_id FROM auth_config WHERE user_id = ?", (user_id,)).fetchone() + fields = [] + vals = [] + for key, val in kwargs.items(): + if key in ("passkey_credential_id", "passkey_public_key", "totp_secret", + "recovery_codes", "temp_password", "must_configure_mfa", "totp_enabled"): + fields.append(f"{key}=?") + vals.append(val) + if not fields: + conn.close() + return + if row is None: + cols = ["user_id"] + [f.split("=")[0] for f in fields] + col_str = ", ".join(cols) + ph = ", ".join("?" * len(cols)) + conn.execute(f"INSERT INTO auth_config ({col_str}) VALUES ({ph})", (user_id, *vals)) + else: + vals.append(user_id) + conn.execute(f"UPDATE auth_config SET {', '.join(fields)} WHERE user_id=?", vals) + conn.commit() + conn.close() + + +def get_temp_password(db_path: str = None, user_id: int = 0) -> str: + cfg = get_auth_config(db_path, user_id) + return cfg["temp_password"] + + +def set_temp_password(password: str, db_path: str = None, user_id: int = 0): + update_auth_config(user_id, temp_password=password, _db_path=db_path) + + +def clear_temp_password(db_path: str = None, user_id: int = 0): + update_auth_config(user_id, temp_password="", must_configure_mfa=0, _db_path=db_path) + + +def get_recovery_codes(db_path: str = None, user_id: int = 0) -> list: + cfg = get_auth_config(db_path, user_id) + raw = cfg["recovery_codes"] + if not raw: + return [] + return raw.split(",") + + +def set_recovery_codes(codes: list, db_path: str = None, user_id: int = 0): + update_auth_config(user_id, recovery_codes=",".join(codes), _db_path=db_path) + + +def has_mfa_configured(db_path: str = None, user_id: int = 0) -> bool: + cfg = get_auth_config(db_path, user_id) + return bool(cfg["passkey_credential_id"]) or bool(cfg["totp_enabled"]) + + +def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int | None: + """Find the user who owns a given WebAuthn credential id (for login).""" + conn = get_db(_db_path(db_path)) + row = conn.execute( + "SELECT a.user_id FROM auth_config a " + "JOIN users u ON u.id = a.user_id " + "WHERE a.passkey_credential_id = ? AND u.status = 'active'", + (credential_id,) + ).fetchone() + conn.close() + return row["user_id"] if row else None + + +# ─── DB init / migration ───────────────────── + +def _ensure_defaults(conn): + """Insert default rows for config tables and the Root user when missing. + Generates a random 6-digit temp password when Root's auth_config is created + (or when a migrated DB has no MFA configured yet). + """ + import secrets + import string + + # Root user (id 0) + root = conn.execute("SELECT id FROM users WHERE id=0").fetchone() + if root is None: + conn.execute( + "INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')" + ) + + # weather_config for Root + count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0] + if count == 0: + conn.execute("INSERT INTO weather_config (user_id, city, lat, lon) " + "VALUES (0, 'Beijing', 39.9042, 116.4074)") + + # clock_config for Root + count = conn.execute("SELECT COUNT(*) FROM clock_config WHERE user_id=0").fetchone()[0] + if count == 0: + conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) " + "VALUES (0, 'Beijing', 'Asia/Shanghai', 1)") + + # auth_config for Root + row = conn.execute("SELECT * FROM auth_config WHERE user_id=0").fetchone() + mfa_configured = bool(row and (row["passkey_credential_id"] or row["totp_enabled"])) + if row is None: + temp_pw = "".join(secrets.choice(string.digits) for _ in range(6)) + conn.execute( + "INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key, " + "totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa) " + "VALUES (0, '', '', '', 0, '', ?, 1)", + (temp_pw,) + ) + _EMITTED_PASSWORDS.append(temp_pw) + print(f"\n{'='*50}", file=sys.stderr) + print(f" 🔐 Root 初始临时密码: {temp_pw}", file=sys.stderr) + print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr) + print(f"{'='*50}\n", file=sys.stderr) + else: + # Migrated DB. If Root already has MFA, no temp password is needed. + if mfa_configured: + if row["temp_password"] or row["must_configure_mfa"]: + conn.execute( + "UPDATE auth_config SET temp_password='', must_configure_mfa=0 WHERE user_id=0" + ) + elif (not row["temp_password"]) and row["must_configure_mfa"]: + # Migrated DB without MFA — generate a fresh temp password. + temp_pw = "".join(secrets.choice(string.digits) for _ in range(6)) + conn.execute( + "UPDATE auth_config SET temp_password=? WHERE user_id=0", + (temp_pw,) + ) + _EMITTED_PASSWORDS.append(temp_pw) + print(f"\n{'='*50}", file=sys.stderr) + print(f" 🔐 迁移已存在数据库 — Root 临时密码: {temp_pw}", file=sys.stderr) + print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr) + print(f"{'='*50}\n", file=sys.stderr) + + conn.commit() + + +# Stores passwords that were just generated, for the startup script to print. +_EMITTED_PASSWORDS: list = [] + + +def _table_columns(conn, table_name): + return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")] + + +def _migrate_auth_config(conn_new, conn_old): + """Special-case migration for auth_config: the legacy table is keyed by + `id` (single user, id=1), the new table is keyed by `user_id`. + The legacy single-user row belongs to Root (user 0). + """ + old_cols = _table_columns(conn_old, "auth_config") + new_cols = _table_columns(conn_new, "auth_config") + if not old_cols: + return + rows = conn_old.execute("SELECT * FROM auth_config").fetchall() + for row in rows: + d = dict(row) + if "user_id" in old_cols: + uid = d.get("user_id") or 0 + else: + # Legacy single-user DB: the id=1 row belongs to Root (user 0). + old_id = d.get("id", 1) + uid = 0 if old_id == 1 else (old_id or 0) + common = [c for c in new_cols if c in old_cols and c not in ("id", "user_id")] + if not common: + continue + col_str = ", ".join(common) + placeholders = ", ".join("?" * len(common)) + conn_new.execute( + f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})", + (uid, *[d[c] for c in common]) + ) + + +def init_db(db_path: str = "data.db"): + """Initialize the database. + + - No database file: create it from the latest SCHEMA and create Root. + - Existing database: rebuild it from the latest SCHEMA, migrate compatible + data (legacy single-user data becomes Root's), fill missing columns with + SCHEMA defaults, then replace the old file (keeping a backup). + """ + if not os.path.exists(db_path): + conn = get_db(db_path) + conn.executescript(SCHEMA) + _ensure_defaults(conn) + conn.close() + return + + # Migrate by rebuilding: create a fresh DB with the latest schema, + # copy compatible data from the old DB, then replace the old DB. + new_db_path = db_path + ".new" + backup_path = db_path + ".backup" + + # Clean up any stale temporary database from a previous failed run. + if os.path.exists(new_db_path): + os.remove(new_db_path) + + conn_new = get_db(new_db_path) + conn_new.executescript(SCHEMA) + + conn_old = get_db(db_path) + # Disable FK enforcement while copying; rows are copied table by table. + conn_old.execute("PRAGMA foreign_keys=OFF") + conn_new.execute("PRAGMA foreign_keys=OFF") + + tables = conn_old.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + + for (table_name,) in tables: + # Tables that no longer exist in the new schema are dropped (e.g. camera_config). + exists_new = conn_new.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table_name,) + ).fetchone() + if not exists_new: + continue + + if table_name == "auth_config": + _migrate_auth_config(conn_new, conn_old) + continue + + old_cols = _table_columns(conn_old, table_name) + new_cols = _table_columns(conn_new, table_name) + common_cols = [c for c in new_cols if c in old_cols] + if not common_cols: + continue + + col_str = ", ".join(common_cols) + placeholders = ", ".join("?" * len(common_cols)) + sql = f"INSERT OR IGNORE INTO {table_name} ({col_str}) VALUES ({placeholders})" + + for row in conn_old.execute(f"SELECT {col_str} FROM {table_name}"): + try: + conn_new.execute(sql, tuple(row)) + except Exception: + # Ignore rows that cannot be migrated (e.g. constraint violations). + pass + + conn_new.commit() + + # Now fill defaults for any tables that are still empty + Root bootstrap. + _ensure_defaults(conn_new) + + conn_old.close() + conn_new.close() + + # Replace old DB with the new one, keeping a backup. + if os.path.exists(backup_path): + os.remove(backup_path) + os.rename(db_path, backup_path) + os.rename(new_db_path, db_path) diff --git a/backend/pomodoro_mgr.py b/backend/pomodoro_mgr.py new file mode 100644 index 0000000..61af5cc --- /dev/null +++ b/backend/pomodoro_mgr.py @@ -0,0 +1,312 @@ +"""Pomodoro timer management: state persistence, countdown, cycle control. + +State is stored per user (one row per user_id). +""" +import json +import os +import time +from datetime import datetime, timedelta +from backend.time_util import now as tz_now +from flask import current_app +from backend.models import get_db + + +DEFAULT_WORK_MINUTES = 25 +DEFAULT_BREAK_MINUTES = 5 +DEFAULT_LONG_BREAK_MINUTES = 15 +DEFAULT_CYCLES_BEFORE_LONG_BREAK = 4 + + +def _uid() -> int: + """Resolve the current user (request session or scheduler thread).""" + try: + from backend.auth import current_user_id + return current_user_id() or 0 + except Exception: + return 0 + + +def _get_db(): + return get_db(current_app.config["DB_PATH"]) + + +def get_state(user_id: int = None) -> dict: + """Return current pomodoro state from DB. + Cycles reset at the start of each new day (daily auto-clear). + """ + if user_id is None: + user_id = _uid() + db = _get_db() + row = db.execute("SELECT * FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone() + if row is None: + db.close() + return _default_state() + today = tz_now().strftime("%Y%m%d") + cycles_date = row["cycles_date"] or "" + cycles = row["cycles"] + # Daily reset: if no date recorded or it's a new day, clear cycles + if cycles_date != today: + cycles = 0 + cycles_date = today + db.execute( + "UPDATE pomodoro_state SET cycles=0, cycles_date=? WHERE user_id=?", + (today, user_id), + ) + db.commit() + db.close() + long_break_minutes = row["long_break_minutes"] or DEFAULT_LONG_BREAK_MINUTES + cycles_before_long_break = row["cycles_before_long_break"] or DEFAULT_CYCLES_BEFORE_LONG_BREAK + + # Compute next break type for display + if row["phase"] == "work": + next_break_long = ((cycles + 1) % cycles_before_long_break) == 0 + else: + next_break_long = ((cycles + 2) % cycles_before_long_break) == 0 + + return { + "running": bool(row["running"]), + "phase": row["phase"], # 'work' or 'break' + "work_minutes": row["work_minutes"], + "break_minutes": row["break_minutes"], + "long_break_minutes": long_break_minutes, + "cycles_before_long_break": cycles_before_long_break, + "next_break_type": "long" if next_break_long else "short", + "end_time": row["end_time"], + "remaining_seconds": row["remaining_seconds"], + "cycles": cycles, + "cycles_date": cycles_date, + "enabled": bool(row["enabled"]), + "display_controls_enabled": bool(row["display_controls_enabled"]), + "work_label": row["work_label"] or "工作中", + "break_label": row["break_label"] or "休息中", + } + + +def _default_state() -> dict: + return { + "running": False, + "phase": "work", + "work_minutes": DEFAULT_WORK_MINUTES, + "break_minutes": DEFAULT_BREAK_MINUTES, + "long_break_minutes": DEFAULT_LONG_BREAK_MINUTES, + "cycles_before_long_break": DEFAULT_CYCLES_BEFORE_LONG_BREAK, + "next_break_type": "short", + "end_time": None, + "remaining_seconds": DEFAULT_WORK_MINUTES * 60, + "cycles": 0, + "enabled": False, + "display_controls_enabled": True, + "cycles_date": "", + "work_label": "工作中", + "break_label": "休息中", + } + + +def _ensure_row(db, user_id: int): + row = db.execute("SELECT id FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone() + if row is None: + db.execute( + """INSERT INTO pomodoro_state + (user_id, running, phase, work_minutes, break_minutes, long_break_minutes, cycles_before_long_break, end_time, remaining_seconds, cycles, enabled, display_controls_enabled, cycles_date, work_label, break_label) + VALUES (?, 0, 'work', ?, ?, ?, ?, NULL, ?, 0, 0, 1, '', '工作中', '休息中')""", + (user_id, DEFAULT_WORK_MINUTES, DEFAULT_BREAK_MINUTES, DEFAULT_LONG_BREAK_MINUTES, + DEFAULT_CYCLES_BEFORE_LONG_BREAK, DEFAULT_WORK_MINUTES * 60), + ) + db.commit() + + +def create_or_update(work_minutes: int = None, break_minutes: int = None, + long_break_minutes: int = None, cycles_before_long_break: int = None, + enabled: bool = None, display_controls_enabled: bool = None, + work_label: str = None, break_label: str = None, + user_id: int = None) -> dict: + """Create or update pomodoro settings. Stops the timer and does not auto-start.""" + if user_id is None: + user_id = _uid() + db = _get_db() + _ensure_row(db, user_id) + + # Apply new settings first so the reset below uses the updated durations. + fields = [] + vals = [] + if work_minutes is not None: + fields.append("work_minutes=?") + vals.append(work_minutes) + if break_minutes is not None: + fields.append("break_minutes=?") + vals.append(break_minutes) + if long_break_minutes is not None: + fields.append("long_break_minutes=?") + vals.append(long_break_minutes) + if cycles_before_long_break is not None: + fields.append("cycles_before_long_break=?") + vals.append(cycles_before_long_break) + if enabled is not None: + fields.append("enabled=?") + vals.append(1 if enabled else 0) + if display_controls_enabled is not None: + fields.append("display_controls_enabled=?") + vals.append(1 if display_controls_enabled else 0) + if work_label is not None: + fields.append("work_label=?") + vals.append(work_label) + if break_label is not None: + fields.append("break_label=?") + vals.append(break_label) + if fields: + vals.append(user_id) + db.execute(f"UPDATE pomodoro_state SET {', '.join(fields)} WHERE user_id=?", vals) + + # Stop the timer and reset to the work phase. Do not auto-start after saving. + db.execute( + """UPDATE pomodoro_state + SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0 + WHERE user_id=?""", + (user_id,) + ) + db.commit() + state = get_state(user_id) + db.close() + current_app.event_bus.publish("pomodoro_updated", state, user_id=user_id) + return state + + +def start(user_id: int = None) -> dict: + """Start or resume the pomodoro timer.""" + if user_id is None: + user_id = _uid() + db = _get_db() + _ensure_row(db, user_id) + state = get_state(user_id) + remaining = state.get("remaining_seconds") or (state["work_minutes"] * 60) + end_time = str(time.time() + remaining) + db.execute( + "UPDATE pomodoro_state SET running=1, end_time=?, remaining_seconds=?, enabled=1 WHERE user_id=?", + (end_time, remaining, user_id), + ) + db.commit() + db.close() + updated = get_state(user_id) + current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) + return updated + + +def pause(user_id: int = None) -> dict: + """Pause the pomodoro timer, saving remaining time.""" + if user_id is None: + user_id = _uid() + db = _get_db() + _ensure_row(db, user_id) + state = get_state(user_id) + remaining = _calc_remaining(state) + db.execute( + "UPDATE pomodoro_state SET running=0, end_time=NULL, remaining_seconds=? WHERE user_id=?", + (remaining, user_id), + ) + db.commit() + db.close() + updated = get_state(user_id) + current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) + return updated + + +def stop(user_id: int = None) -> dict: + """Stop and reset the pomodoro timer.""" + if user_id is None: + user_id = _uid() + db = _get_db() + _ensure_row(db, user_id) + db.execute( + "UPDATE pomodoro_state SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0 WHERE user_id=?", + (user_id,) + ) + db.commit() + db.close() + updated = get_state(user_id) + current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) + return updated + + +def _advance_phase(user_id: int = None) -> dict: + """Advance to the next phase (work ↔ break), start the timer, persist, notify. + Determines short vs long break based on cycles_before_long_break setting.""" + if user_id is None: + user_id = _uid() + db = _get_db() + _ensure_row(db, user_id) + state = get_state(user_id) + + if state["phase"] == "work": + # Work → break: decide short or long + cycles_before_long = state.get("cycles_before_long_break", DEFAULT_CYCLES_BEFORE_LONG_BREAK) + is_long_break = ((state["cycles"] + 1) % cycles_before_long) == 0 + if is_long_break: + duration = state.get("long_break_minutes", DEFAULT_LONG_BREAK_MINUTES) * 60 + else: + duration = state["break_minutes"] * 60 + next_phase = "break" + cycles = state["cycles"] # cycles unchanged on work→break + else: + # Break → work: always use work duration + duration = state["work_minutes"] * 60 + next_phase = "work" + cycles = state["cycles"] + 1 + + end_time = str(time.time() + duration) + today = tz_now().strftime("%Y%m%d") + db.execute( + "UPDATE pomodoro_state SET running=1, phase=?, end_time=?, remaining_seconds=?, cycles=?, cycles_date=? WHERE user_id=?", + (next_phase, end_time, duration, cycles, today, user_id), + ) + db.commit() + db.close() + updated = get_state(user_id) + current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) + return updated + + +def skip(user_id: int = None) -> dict: + """Skip the current phase and start the next phase immediately (work ↔ break).""" + return _advance_phase(user_id) + + +def dismiss(user_id: int = None) -> dict: + """Dismiss ringing and auto-advance to next phase.""" + return _advance_phase(user_id) + + +def _calc_remaining(state: dict) -> int: + """Calculate remaining seconds from state.""" + if not state.get("running"): + return state.get("remaining_seconds", state["work_minutes"] * 60) + end_time_str = state.get("end_time") + if not end_time_str: + return state.get("remaining_seconds", state["work_minutes"] * 60) + try: + remaining = int(float(end_time_str) - time.time()) + return max(0, remaining) + except (ValueError, TypeError): + return state.get("remaining_seconds", state["work_minutes"] * 60) + + +def check_and_trigger(user_id: int = None) -> bool: + """Called by scheduler to check if pomodoro timer has expired. + Returns True if triggered (ringing should start).""" + if user_id is None: + user_id = _uid() + db = _get_db() + _ensure_row(db, user_id) + state = get_state(user_id) + db.close() + + if not state.get("running") or not state.get("enabled"): + return False + + remaining = _calc_remaining(state) + if remaining > 0: + return False + + # Timer expired; auto-advance phase on the backend once, then notify displays. + updated = dismiss(user_id) + current_app.event_bus.publish("pomodoro_due", updated, user_id=user_id) + return True diff --git a/backend/routes.py b/backend/routes.py new file mode 100644 index 0000000..99fe441 --- /dev/null +++ b/backend/routes.py @@ -0,0 +1,1672 @@ +"""REST API routes and SSE endpoint. + +Multi-user rules enforced here: +- Every data route operates on `current_user_id()` (session user). +- User management (/api/admin/*) is Root-only and requires re-authentication + (TOTP first, Passkey fallback) for register/edit/delete. +- Temp accounts log in with a server-held temp TOTP; they are forced through + the MFA setup page until they configure (and verify) one real login method, + at which point the temp TOTP is invalidated. +""" +import json +import os +import queue +import io +import time as _time +import secrets +from datetime import datetime +from flask import ( + Blueprint, request, jsonify, Response, + render_template, current_app, stream_with_context, + session, redirect, url_for, +) +from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr +from backend.auth import ( + login_required, login_or_temp_required, display_login_required, admin_required, + is_authenticated, is_temp_pending, is_admin, current_user_id, + set_authenticated, set_temp_pending, clear_session, + verify_login, verify_totp, verify_recovery_code, + check_rate_limit, record_failure, record_success, + require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password, +) +from backend.models import ( + get_auth_config, update_auth_config, set_temp_password, clear_temp_password, + set_recovery_codes, get_recovery_codes, has_mfa_configured, + get_display_config, set_display_mode, set_custom_html, + set_custom_html_template, apply_custom_html, + get_users, get_user, create_user, update_user, delete_user, + is_temp_account, is_admin_user, finalize_temp_account, + find_user_by_credential_id, bump_all_session_versions, +) +from backend.webauthn_mgr import ( + begin_registration, complete_registration, + begin_authentication, complete_authentication, +) + + +api = Blueprint("api", __name__) + + +def _cur() -> int: + """Current session user id (0 = Root default).""" + return current_user_id() or 0 + + +# ════════════════════════════════════════════ +# CSRF / Origin Check +# ════════════════════════════════════════════ + +@api.before_request +def _check_origin(): + """Protect state-changing requests against CSRF by verifying Origin/Referer. + Allows same-origin requests, extension-internal calls, and GET/HEAD/OPTIONS. + """ + if request.method in ("GET", "HEAD", "OPTIONS"): + return None + + # Login / logout endpoints are exempt (they set/clear the cookie itself) + if request.path in ("/api/auth/login", "/api/auth/logout", "/api/auth/login/recovery", + "/api/auth/webauthn/login/begin", "/api/auth/webauthn/login/complete", + "/login", "/mfa-setup"): + return None + + origin = request.headers.get("Origin", "") + referer = request.headers.get("Referer", "") + + # Build expected origin from the request itself + expected = f"{request.scheme}://{request.host}" + + if origin and origin != expected: + return jsonify({"error": "CSRF: Origin mismatch"}), 403 + if referer and not referer.startswith(expected): + # Referer may include path, so we only check the prefix + return jsonify({"error": "CSRF: Referer mismatch"}), 403 + + return None + + +# ════════════════════════════════════════════ +# AUTH +# ════════════════════════════════════════════ + +@api.route("/login") +def login_page(): + """Render login page. Control gets a full-input page; display gets a numpad.""" + next_url = request.args.get("next", "/") + view = request.args.get("view", "display") + if view == "control" or "control" in next_url: + return render_template("login_control.html", next=next_url) + return render_template("login.html", next=next_url) + + +@api.route("/register-request") +def register_request_page(): + """Public page where a prospective user fills in their info (admin approves later).""" + return render_template("register_request.html") + + +@api.route("/passkey") +def passkey_page(): + """Passkey bridge page: loaded inside the ASWebAuthenticationSession sheet + (Safari environment supports WebAuthn/passkeys WITHOUT app associated + domains). It fetches the stashed {op, options}, runs the standard + WebAuthn ceremony, then redirects back via dashboard://passkey.""" + return render_template("passkey.html") + + +# ─── 一次性 Passkey 参数暂存(小窗是独立 ephemeral 会话,读不到主容器 storage)─── +_PASSKEY_STASH: dict = {} # sid -> {"op": str, "options": dict, "expires": float, "result": dict} +_PASSKEY_STASH_TTL = 300 # 秒(用户在小窗 Passkey 面板操作需要时间) + + +def _clean_passkey_stash(): + now = _time.time() + expired = [k for k, v in _PASSKEY_STASH.items() if v["expires"] < now] + for k in expired: + _PASSKEY_STASH.pop(k, None) + + +@api.route("/api/passkey/stash", methods=["POST"]) +def passkey_stash(): + """Store passkey ceremony parameters once, return a short-lived random id. + Called by the Passkey bridge middleware from the main WKWebView; the + ceremony itself runs inside the ephemeral sheet page which consumes the id.""" + data = request.get_json(silent=True) or {} + op = data.get("op") + options = data.get("options") + if op not in ("creation", "assertion") or not isinstance(options, dict): + return jsonify({"error": "无效的 Passkey 请求"}), 400 + _clean_passkey_stash() + sid = secrets.token_urlsafe(16) + _PASSKEY_STASH[sid] = {"op": op, "options": options, "expires": _time.time() + _PASSKEY_STASH_TTL} + return jsonify({"sid": sid}), 201 + + +@api.route("/api/passkey/stash/", methods=["GET"]) +def passkey_stash_get(sid): + """Read the stashed parameters. 只读不删——entry 必须存活到小窗存回结果 + (result 接口),真正的"一次性"由 result GET 取走即删保证。""" + entry = _PASSKEY_STASH.get(sid) + if not entry or entry["expires"] < _time.time(): + return jsonify({"error": "参数已过期,请重试"}), 410 + return jsonify({"op": entry["op"], "options": entry["options"]}) + + +@api.route("/api/passkey/stash//result", methods=["POST"]) +def passkey_stash_result(sid): + """小窗页面完成 WebAuthn 后把结果存回服务端。 + credential 较大,走 POST body(回调 URL 只带短 sid,避免截断)。""" + entry = _PASSKEY_STASH.get(sid) + if not entry or entry["expires"] < _time.time(): + return jsonify({"error": "参数已过期,请重试"}), 410 + data = request.get_json(silent=True) or {} + credential = data.get("credential") + if not isinstance(credential, dict): + return jsonify({"error": "缺少 credential"}), 400 + entry["result"] = { + "credential": credential, + "user_name": data.get("user_name", ""), + } + return jsonify({"ok": True}) + + +@api.route("/api/passkey/stash//result", methods=["GET"]) +def passkey_stash_result_get(sid): + """App 取回小窗完成的结果(一次性,取走即删)。""" + entry = _PASSKEY_STASH.get(sid) + if not entry or entry["expires"] < _time.time(): + return jsonify({"error": "结果已过期或不存在"}), 410 + result = entry.pop("result", None) + _PASSKEY_STASH.pop(sid, None) # 结果取走即清,防重放 + if not result: + return jsonify({"error": "结果不存在"}), 410 + return jsonify(result) + + +# ─── Unified Login (TOTP + Temp Password) ──── + +@api.route("/api/auth/login", methods=["POST"]) +def api_login(): + """Unified login: accepts 6-digit code. + Backend auto-detects whether it's a user TOTP, a temp-account TOTP, or + Root's temp password. Rate-limited (progressive ban: 5→5min, 10→15min, 15+→24hr). + """ + # Check rate limit before anything else + rl = check_rate_limit() + if not rl["ok"]: + return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 + + data = request.get_json() or {} + code = data.get("code", "") + view = data.get("view", "control") + + result = verify_login(code) + if result is None: + record_failure() + return jsonify({"error": "验证码错误"}), 401 + + resp = {"ok": True, "method": result["method"]} + uid = result["user_id"] + temp_account = result["temp_account"] + + if result["method"] == "totp": + if temp_account: + # Temp account: server-held temp TOTP matched → force registration flow. + record_success() + set_temp_pending(view, uid) + resp["must_configure_mfa"] = True + else: + cfg = get_auth_config(user_id=uid) + if not cfg.get("totp_enabled"): + return jsonify({"error": "TOTP 尚未启用"}), 403 + record_success() + set_authenticated(view, uid) + + else: # temp_password (Root initial bootstrap) + record_success() + set_temp_pending(view, uid) + resp["must_configure_mfa"] = True + + return jsonify(resp) + + +# ─── Recovery Code Login ───────────────────── + +@api.route("/api/auth/login/recovery", methods=["POST"]) +def api_login_recovery(): + """Login with a recovery code (one-time use). Rate-limited (same pool as TOTP).""" + rl = check_rate_limit() + if not rl["ok"]: + return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 + + data = request.get_json() or {} + code = data.get("code", "") + view = data.get("view", "control") + + uid = verify_recovery_code(code) + if uid is not None: + record_success() + set_authenticated(view, uid) + return jsonify({"ok": True}) + + record_failure() + return jsonify({"error": "恢复码错误或已使用"}), 401 + + +# ─── Re-authentication ─────────────────────── +# High-risk operations (user management, MFA changes) require re-confirming the +# identity of the CURRENT user. TOTP is primary; a Passkey ceremony is the +# fallback when the user has no TOTP configured. + +@api.route("/api/auth/reauth", methods=["POST"]) +def api_reauth(): + """Re-authenticate before sensitive operations. Requires TOTP code. + Only callable from an authenticated (or temp-pending) session — otherwise + this endpoint would be a public TOTP oracle. Rate-limited like login. + """ + if not is_authenticated() and not is_temp_pending(): + return jsonify({"error": "Unauthorized", "login_required": True}), 401 + + rl = check_rate_limit() + if not rl["ok"]: + return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 + + data = request.get_json() or {} + code = data.get("code", "") + + cfg = get_auth_config(user_id=_cur()) + if cfg.get("totp_enabled"): + if verify_totp(code, user_id=_cur()): + record_success() + mark_reauth() + return jsonify({"ok": True}) + record_failure() + return jsonify({"error": "验证码错误"}), 401 + + # No TOTP configured: temp-pending sessions pass without a code; + # passkey-only users must use the Passkey re-auth ceremony instead. + if is_temp_pending(): + mark_reauth() + return jsonify({"ok": True}) + return jsonify({"error": "未配置 TOTP,请使用 Passkey 重验证"}), 400 + + +@api.route("/api/auth/reauth/webauthn/begin", methods=["POST"]) +@login_required +def api_reauth_webauthn_begin(): + """Start a Passkey re-authentication ceremony (fallback when no TOTP).""" + cfg = get_auth_config(user_id=_cur()) + stored_id = cfg.get("passkey_credential_id", "") + if not stored_id: + return jsonify({"error": "未注册 Passkey"}), 400 + try: + options = begin_authentication(request, allow_credential_ids=[stored_id]) + return jsonify(options) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@api.route("/api/auth/reauth/webauthn/complete", methods=["POST"]) +@login_required +def api_reauth_webauthn_complete(): + """Complete a Passkey re-authentication ceremony.""" + data = request.get_json() or {} + credential = data.get("credential", {}) + + cfg = get_auth_config(user_id=_cur()) + stored_id = cfg.get("passkey_credential_id", "") + stored_key = cfg.get("passkey_public_key", "") + if not stored_id or not stored_key: + return jsonify({"error": "未注册 Passkey"}), 400 + + try: + result = complete_authentication( + request, credential, + stored_credential_id=stored_id, + stored_public_key=stored_key, + ) + if result.get("credential_id") != stored_id: + return jsonify({"error": "Passkey 凭证不匹配"}), 401 + mark_reauth() + return jsonify({"ok": True}) + except ValueError as e: + return jsonify({"error": str(e)}), 401 + except Exception as e: + return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 + + +# ─── Passkey (WebAuthn) Login ──────────────── + +@api.route("/api/auth/webauthn/login/begin", methods=["POST"]) +def api_webauthn_login_begin(): + """Start WebAuthn authentication. Returns challenge options for the frontend.""" + try: + options = begin_authentication(request) + return jsonify(options) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@api.route("/api/auth/webauthn/login/complete", methods=["POST"]) +def api_webauthn_login_complete(): + """Complete WebAuthn authentication. Finds the owning user by credential id.""" + rl = check_rate_limit() + if not rl["ok"]: + return jsonify({"error": rl["message"], "retry_after": rl["retry_after"]}), 429 + + data = request.get_json() or {} + view = data.get("view", "control") + credential = data.get("credential", {}) + credential_b64 = credential.get("id", "") + + # Locate the user that owns this credential (frontend sends base64url id). + uid = None + if credential_b64: + try: + import base64 + padded = credential_b64 + "=" * ((4 - len(credential_b64) % 4) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")) + uid = find_user_by_credential_id(raw.hex()) + except Exception: + uid = None + + if uid is None: + record_failure() + return jsonify({"error": "未注册 Passkey"}), 400 + + cfg = get_auth_config(user_id=uid) + stored_id_hex = cfg.get("passkey_credential_id", "") + stored_public_key = cfg.get("passkey_public_key", "") + if not stored_id_hex or not stored_public_key: + record_failure() + return jsonify({"error": "未注册 Passkey"}), 400 + + try: + result = complete_authentication( + request, credential, + stored_credential_id=credential_b64, + stored_public_key=stored_public_key, + ) + # complete_authentication returns the credential id in hex. + if result.get("credential_id") != stored_id_hex: + record_failure() + return jsonify({"error": "Passkey 凭证不匹配"}), 401 + record_success() + set_authenticated(view, uid) + return jsonify({"ok": True}) + except ValueError as e: + record_failure() + return jsonify({"error": str(e)}), 401 + except Exception as e: + return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 + + +# ─── Logout ────────────────────────────────── + +@api.route("/api/auth/logout", methods=["POST"]) +def api_logout(): + clear_session() + return jsonify({"ok": True}) + + +# ─── Auth Status ───────────────────────────── + +def _user_payload(uid: int) -> dict: + u = get_user(uid) or {} + return { + "id": uid, + "remark": u.get("remark", "") or "", + "is_admin": is_admin_user(uid), + "is_temp": bool(u.get("is_temp", 0)), + } + + +@api.route("/api/auth/status", methods=["GET"]) +def api_auth_status(): + """Return current auth status. Used by frontend to decide login flow.""" + uid = current_user_id() + if is_authenticated(): + return jsonify({ + "authenticated": True, + "mfa_configured": has_mfa_configured(user_id=uid or 0), + "user": _user_payload(uid or 0), + }) + if is_temp_pending(): + return jsonify({ + "authenticated": False, + "temp_pending": True, + "mfa_configured": has_mfa_configured(user_id=uid or 0), + "user": _user_payload(uid or 0), + }) + return jsonify({ + "authenticated": False, + "temp_pending": False, + "mfa_configured": False, + }) + + +# ════════════════════════════════════════════ +# USER SELF-SERVICE REQUEST +# ════════════════════════════════════════════ +# The user fills in their info; the admin confirms it and a temp account +# (with a server-held temp TOTP) is generated automatically. + +_REG_REQ_LIMIT: dict[str, list] = {} # ip -> [timestamps] + + +@api.route("/api/auth/register-request", methods=["POST"]) +def api_register_request(): + """Public: a prospective user submits their remark for admin approval. + Rate-limited per IP (5 requests per 10 minutes).""" + ip = request.remote_addr or "unknown" + now = _time.time() + stamps = [t for t in _REG_REQ_LIMIT.get(ip, []) if now - t < 600] + if len(stamps) >= 5: + _REG_REQ_LIMIT[ip] = stamps + return jsonify({"error": "申请过于频繁,请稍后再试"}), 429 + stamps.append(now) + _REG_REQ_LIMIT[ip] = stamps + + data = request.get_json() or {} + remark = (data.get("remark") or "").strip() + if not remark: + return jsonify({"error": "请填写你的称呼/备注"}), 400 + if len(remark) > 64: + return jsonify({"error": "备注过长"}), 400 + + create_user(remark=remark, role="user", is_temp=0, status="pending") + return jsonify({"ok": True, "message": "已提交申请,请等待管理员确认"}), 201 + + +# ════════════════════════════════════════════ +# MFA CONFIGURATION +# ════════════════════════════════════════════ + +@api.route("/mfa-setup") +def mfa_setup_page(): + """MFA configuration page. Shown after temp-password / temp-account login.""" + if not is_temp_pending() and not is_authenticated(): + return redirect(url_for("api.login_page", next="/mfa-setup")) + return render_template("mfa_setup.html") + + +# ─── WebAuthn Registration ─────────────────── + +@api.route("/api/auth/webauthn/register/begin", methods=["POST"]) +@login_or_temp_required +def api_webauthn_register_begin(): + """Start WebAuthn registration for the current user.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + uid = _cur() + cfg = get_auth_config(user_id=uid) + existing_id = cfg.get("passkey_credential_id", "") + user = get_user(uid) or {} + try: + options = begin_registration(request, uid, display_name=user.get("remark", ""), existing_credential_id=existing_id) + return jsonify(options) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@api.route("/api/auth/webauthn/register/complete", methods=["POST"]) +@login_or_temp_required +def api_webauthn_register_complete(): + """Complete WebAuthn registration. Saves credential for the current user. + If the account is a temp account, finalizing it invalidates the temp TOTP. + """ + data = request.get_json() or {} + credential = data.get("credential", {}) + uid = _cur() + + try: + result = complete_registration(request, credential) + if result.get("user_id") != uid: + return jsonify({"error": "注册上下文不匹配,请重试"}), 400 + update_auth_config( + uid, + passkey_credential_id=result["credential_id"], + passkey_public_key=result["public_key"], + ) + finalized = False + if is_temp_account(uid): + finalize_temp_account(uid) + finalized = True + # The user proved access to the account — mark the session authenticated. + set_authenticated(session.get("mfa_view", "control"), uid) + return jsonify({"ok": True, "finalized": finalized}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 + + +# ─── TOTP Setup ────────────────────────────── + +@api.route("/api/auth/totp/setup", methods=["POST"]) +@login_or_temp_required +def api_totp_setup_begin(): + """Generate a TOTP secret for the current user.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + import pyotp + import qrcode + import base64 + + uid = _cur() + user = get_user(uid) or {} + + # Generate a new secret + secret = pyotp.random_base32() + + # Store temporarily (will be enabled after verification) + update_auth_config(uid, totp_secret=secret, totp_enabled=0) + + # Build provisioning URI + issuer = "Cal Display" + name = (user.get("remark") or f"user_{uid}")[:64] + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=name, issuer_name=issuer) + + # Generate QR code as base64 PNG + qr = qrcode.make(uri) + buf = io.BytesIO() + qr.save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + + return jsonify({ + "secret": secret, + "uri": uri, + "qr_base64": qr_b64, + }) + + +@api.route("/api/auth/totp/verify", methods=["POST"]) +@login_or_temp_required +def api_totp_verify(): + """Verify a TOTP code to enable TOTP MFA for the current user. + For temp accounts this also converts the account (temp TOTP is discarded). + """ + data = request.get_json() or {} + code = data.get("code", "") + uid = _cur() + + if verify_totp(code, user_id=uid): + update_auth_config(uid, totp_enabled=1) + # Generate recovery codes on first TOTP enable + cfg = get_auth_config(user_id=uid) + if not cfg.get("recovery_codes"): + codes = generate_recovery_codes() + set_recovery_codes(codes, user_id=uid) + + finalized = False + if is_temp_account(uid): + finalize_temp_account(uid) + finalized = True + set_authenticated(session.get("mfa_view", "control"), uid) + + return jsonify({ + "ok": True, + "recovery_codes": get_recovery_codes(user_id=uid), + "finalized": finalized, + }) + + return jsonify({"error": "验证码错误,请重试"}), 400 + + +# ─── MFA Complete / Skip Temp Password ───── + +@api.route("/api/auth/mfa/complete", methods=["POST"]) +def api_mfa_complete(): + """Called after user has configured at least one MFA method. + Works for temp-password (Root bootstrap) and temp-account sessions. + """ + if not is_temp_pending() and not is_authenticated(): + return jsonify({"error": "请先登录"}), 401 + uid = _cur() + cfg = get_auth_config(user_id=uid) + if not cfg.get("passkey_credential_id") and not cfg.get("totp_enabled"): + return jsonify({"error": "请至少配置一种 MFA 方式"}), 400 + + if is_temp_account(uid): + finalize_temp_account(uid) + clear_temp_password(user_id=uid) + update_auth_config(uid, must_configure_mfa=0) + set_authenticated(session.get("mfa_view", "control"), uid) + return jsonify({"ok": True}) + + +# ─── Recovery Codes ────────────────────────── + +@api.route("/api/auth/recovery-codes", methods=["GET"]) +@login_or_temp_required +def api_get_recovery_codes(): + """Get remaining recovery codes for the current user.""" + uid = _cur() + cfg = get_auth_config(user_id=uid) + if not cfg.get("recovery_codes"): + return jsonify({"codes": [], "remaining": 0}) + codes = get_recovery_codes(user_id=uid) + return jsonify({"codes": codes, "remaining": len(codes)}) + + +@api.route("/api/auth/recovery-codes/regenerate", methods=["POST"]) +@login_or_temp_required +def api_regenerate_recovery_codes(): + """Regenerate recovery codes. Requires recent re-authentication.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + codes = generate_recovery_codes() + set_recovery_codes(codes, user_id=_cur()) + return jsonify({"codes": codes, "remaining": len(codes)}) + + +# ─── MFA Status (for control panel settings) ─ + +@api.route("/api/auth/mfa/status", methods=["GET"]) +@login_required +def api_mfa_status(): + """Return what MFA methods are configured for the current user.""" + uid = _cur() + cfg = get_auth_config(user_id=uid) + codes = get_recovery_codes(user_id=uid) + return jsonify({ + "passkey_enabled": bool(cfg.get("passkey_credential_id")), + "totp_enabled": bool(cfg.get("totp_enabled")), + "recovery_codes_remaining": len(codes), + "is_temp_account": is_temp_account(uid), + }) + + +# ════════════════════════════════════════════ +# THEME(账户独立主题) +# ════════════════════════════════════════════ + +def _effective_theme(uid: int, mode: str) -> str: + """解析主题模式为实际生效值:dark / light / auto。 + auto 模式按用户天气配置的经纬度 + 本地时区计算日出日落, + 白天→浅色,夜晚→深色;无位置数据时按本地 6:00-18:00 粗判。""" + if mode == "dark": + return "dark" + if mode == "light": + return "light" + try: + from backend.time_util import now as tz_now + from backend.sun_util import is_daytime + cfg = weather_mgr.get_config(user_id=uid) + lat = cfg.get("lat") + lon = cfg.get("lon") + now_local = tz_now() + if lat is not None and lon is not None: + return "light" if is_daytime(lat, lon, now_local) else "dark" + return "light" if 6 <= now_local.hour < 18 else "dark" + except Exception: + return "dark" + + +@api.route("/api/theme", methods=["GET"]) +@login_required +def get_theme(): + """返回当前用户的主题设置:mode 为用户选择(dark/light/auto), + effective 为实际生效(auto 已解析为 dark/light)。""" + uid = _cur() + mode = (get_user(uid) or {}).get("theme") or "dark" + return jsonify({ + "mode": mode, + "effective": _effective_theme(uid, mode), + }) + + +@api.route("/api/theme", methods=["PUT"]) +@login_required +def set_theme(): + """保存主题设置并广播给该用户的显示端。""" + data = request.get_json() or {} + mode = data.get("mode") + if mode not in ("dark", "light", "auto"): + return jsonify({"error": "无效的主题模式"}), 400 + uid = _cur() + update_user(uid, theme=mode) + effective = _effective_theme(uid, mode) + current_app.event_bus.publish("theme_changed", {"mode": mode, "effective": effective}, user_id=uid) + return jsonify({"ok": True, "mode": mode, "effective": effective}) + + +# ════════════════════════════════════════════ +# USER MANAGEMENT (ADMIN) +# ════════════════════════════════════════════ + +@api.route("/api/admin/users", methods=["GET"]) +@admin_required +def admin_list_users(): + """List all users (including pending requests). No secrets are exposed.""" + users = [] + for u in get_users(): + users.append({ + "id": u["id"], + "remark": u["remark"], + "role": u["role"], + "is_temp": bool(u["is_temp"]), + "status": u["status"], + "created_at": u["created_at"], + "has_mfa": has_mfa_configured(user_id=u["id"]), + }) + return jsonify(users) + + +@api.route("/api/admin/users", methods=["POST"]) +@admin_required +def admin_create_user(): + """Register a user. Only admins can do this — re-auth required. + + mode='admin_fills' → formal account immediately (admin then provisions TOTP + or Passkey on the user's behalf). + mode='user_fills' → temp account with a server-held temp TOTP. + """ + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + + data = request.get_json() or {} + remark = (data.get("remark") or "").strip()[:64] + mode = data.get("mode", "admin_fills") + if mode not in ("admin_fills", "user_fills"): + return jsonify({"error": "无效的注册模式"}), 400 + + if mode == "user_fills": + import pyotp + uid = create_user(remark=remark, role="user", is_temp=1, + temp_totp_secret=pyotp.random_base32(), status="active") + return jsonify({"ok": True, "user": _user_payload(uid), "is_temp": True}), 201 + + uid = create_user(remark=remark, role="user", is_temp=0, status="active") + return jsonify({"ok": True, "user": _user_payload(uid)}), 201 + + +@api.route("/api/admin/users/confirm", methods=["POST"]) +@admin_required +def admin_confirm_request(): + """Confirm a pending self-service request → temp account with temp TOTP.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + + data = request.get_json() or {} + request_id = data.get("request_id") + if request_id is None: + return jsonify({"error": "request_id required"}), 400 + + u = get_user(request_id) + if not u: + return jsonify({"error": "申请不存在"}), 404 + if u.get("status") != "pending": + return jsonify({"error": "该申请已处理"}), 400 + + import pyotp + secret = pyotp.random_base32() + update_user(request_id, is_temp=1, temp_totp_secret=secret, status="active") + return jsonify({"ok": True, "user": _user_payload(request_id)}) + + +@api.route("/api/admin/users/", methods=["PUT"]) +@admin_required +def admin_update_user(user_id): + """Edit a user's remark. Re-auth required.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + + data = request.get_json() or {} + remark = (data.get("remark") or "").strip()[:64] + if not remark: + return jsonify({"error": "备注不能为空"}), 400 + update_user(user_id, remark=remark) + return jsonify({"ok": True, "user": _user_payload(user_id)}) + + +@api.route("/api/admin/users/", methods=["DELETE"]) +@admin_required +def admin_delete_user(user_id): + """Delete a user and ALL of their data. Re-auth required.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + + if user_id == 0: + return jsonify({"error": "无法删除 Root 用户"}), 400 + if user_id == _cur(): + return jsonify({"error": "不能删除当前登录的管理员"}), 400 + + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + + delete_user(user_id) + return jsonify({"ok": True}) + + +@api.route("/api/admin/users//totp", methods=["GET"]) +@admin_required +def admin_user_temp_totp(user_id): + """Real-time temp TOTP code for a temp account (admin helper). + Only the live code is returned — the secret itself never leaves the server. + High-risk view: requires re-authentication. + """ + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + if not u.get("is_temp"): + return jsonify({"error": "该账户不是临时账户"}), 400 + from backend.auth import current_temp_totp_code + return jsonify({ + "code": current_temp_totp_code(user_id), + "remark": u.get("remark", ""), + }) + + +@api.route("/api/admin/users//totp/begin", methods=["POST"]) +@admin_required +def admin_provision_totp_begin(user_id): + """Admin-fills mode: provision a TOTP for a formal user (secret + QR shown + to the admin, who relays it to the user).""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + if u.get("is_temp"): + return jsonify({"error": "临时账户由用户本人配置登录方式"}), 400 + + import pyotp + import qrcode + import base64 + + secret = pyotp.random_base32() + update_auth_config(user_id, totp_secret=secret, totp_enabled=0) + + name = (u.get("remark") or f"user_{user_id}")[:64] + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=name, issuer_name="Cal Display") + qr = qrcode.make(uri) + buf = io.BytesIO() + qr.save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + + return jsonify({"secret": secret, "uri": uri, "qr_base64": qr_b64}) + + +@api.route("/api/admin/users//totp/verify", methods=["POST"]) +@admin_required +def admin_provision_totp_verify(user_id): + """Verify the user's TOTP code (entered by the user into their app) and enable it.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + data = request.get_json() or {} + code = data.get("code", "") + + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + if u.get("is_temp"): + return jsonify({"error": "临时账户由用户本人配置登录方式"}), 400 + + if not verify_totp(code, user_id=user_id): + return jsonify({"error": "验证码错误,请让用户重试"}), 400 + + update_auth_config(user_id, totp_enabled=1) + cfg = get_auth_config(user_id=user_id) + if not cfg.get("recovery_codes"): + codes = generate_recovery_codes() + set_recovery_codes(codes, user_id=user_id) + return jsonify({"ok": True, "recovery_codes": get_recovery_codes(user_id=user_id)}) + + +@api.route("/api/admin/users//passkey/begin", methods=["POST"]) +@admin_required +def admin_provision_passkey_begin(user_id): + """Admin-fills mode: start a Passkey registration ceremony for the target + user. The ceremony completes in the admin's browser (literal interpretation + of '由管理员电脑代为注册').""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + if u.get("is_temp"): + return jsonify({"error": "临时账户由用户本人配置登录方式"}), 400 + + cfg = get_auth_config(user_id=user_id) + existing_id = cfg.get("passkey_credential_id", "") + try: + options = begin_registration(request, user_id, + display_name=u.get("remark", ""), + existing_credential_id=existing_id) + return jsonify(options) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@api.route("/api/admin/users//passkey/complete", methods=["POST"]) +@admin_required +def admin_provision_passkey_complete(user_id): + """Complete the passkey ceremony and store the credential for the target user.""" + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + data = request.get_json() or {} + credential = data.get("credential", {}) + + u = get_user(user_id) + if not u: + return jsonify({"error": "用户不存在"}), 404 + + try: + result = complete_registration(request, credential) + if result.get("user_id") != user_id: + return jsonify({"error": "注册上下文不匹配,请重试"}), 400 + update_auth_config( + user_id, + passkey_credential_id=result["credential_id"], + passkey_public_key=result["public_key"], + ) + return jsonify({"ok": True}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 + + +# ════════════════════════════════════════════ +# DISPLAY PAGES +# ════════════════════════════════════════════ + +@api.route("/") +@api.route("/index") +def display_page(): + cfg = get_display_config(user_id=_cur()) + if cfg.get("mode") == "custom": + return render_template("display_custom.html", custom_html=cfg.get("custom_html", "")) + if not is_authenticated() and not is_temp_pending(): + return redirect(url_for("api.login_page", next=request.path)) + return render_template("display.html") + + +# ════════════════════════════════════════════ +# CONTROL PAGE +# ════════════════════════════════════════════ + +@api.route("/control") +@login_required +def control_page(): + return render_template("control.html") + + +# ════════════════════════════════════════════ +# DISPLAY / SSE / STATE +# ════════════════════════════════════════════ + +@api.route("/api/events") +def sse_stream(): + """Server-Sent Events for real-time updates (scoped to the current user).""" + if not is_authenticated() and not is_temp_pending(): + return jsonify({"error": "Unauthorized", "login_required": True}), 401 + uid = _cur() + + def generate(): + q = current_app.event_bus.subscribe(user_id=uid) + try: + yield "data: {\"type\":\"connected\"}\n\n" + while True: + try: + msg = q.get(timeout=15) + yield f"data: {msg}\n\n" + except queue.Empty: + yield "data: {\"type\":\"heartbeat\"}\n\n" + finally: + current_app.event_bus.unsubscribe(q) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +# ─── Display State (needed by display page) ─ + +@api.route("/api/display/state", methods=["GET"]) +@display_login_required +def get_display_state(): + """Return all data needed by the display page (read-only for display, full for control).""" + uid = _cur() + events = calendar_mgr.get_events(days=7, user_id=uid) + weather = weather_mgr.get_cached_weather(user_id=uid) + alarms = alarm_mgr.get_alarms(user_id=uid) + countdowns = countdown_mgr.get_countdowns(user_id=uid) + groups = calendar_mgr.get_groups(user_id=uid) + pomodoro = pomodoro_mgr.get_state(user_id=uid) + hourly = weather_mgr.get_cached_hourly(user_id=uid) + if not hourly: + hourly = weather_mgr.fetch_hourly(user_id=uid) + update_schedule = weather_mgr.get_next_update_times(user_id=uid) + minutely = weather_mgr.get_cached_minutely(user_id=uid) + warnings = weather_mgr.get_cached_warnings(user_id=uid) + + return jsonify({ + "events": events, + "weather": weather, + "hourly": hourly, + "alarms": alarms, + "countdowns": countdowns, + "groups": [g for g in groups if g["enabled"]], + "pomodoro": pomodoro, + "update_schedule": update_schedule, + "minutely": minutely, + "warnings": warnings, + }) + + +@api.route("/api/display/mode", methods=["GET"]) +@login_required +def get_display_mode(): + cfg = get_display_config(user_id=_cur()) + return jsonify(cfg) + + +@api.route("/api/display/mode", methods=["PUT"]) +@login_required +def update_display_mode(): + data = request.get_json() or {} + mode = data.get("mode", "") + uid = _cur() + if set_display_mode(mode, user_id=uid): + current_app.event_bus.publish("display_mode_changed", {"mode": mode}, user_id=uid) + return jsonify({"ok": True}) + return jsonify({"error": "Invalid mode"}), 400 + + +@api.route("/api/display/custom_html", methods=["PUT"]) +@login_required +def update_custom_html(): + data = request.get_json() or {} + html = data.get("html", "") + uid = _cur() + set_custom_html(html, user_id=uid) + current_app.event_bus.publish("display_mode_changed", {"mode": "custom"}, user_id=uid) + return jsonify({"ok": True}) + + +@api.route("/api/display/custom_html/", methods=["PUT"]) +@login_required +def update_custom_html_template(index): + data = request.get_json() or {} + html = data.get("html", "") + uid = _cur() + if index == 0: + set_custom_html(html, user_id=uid) + return jsonify({"ok": True}) + if set_custom_html_template(index, html, user_id=uid): + return jsonify({"ok": True}) + return jsonify({"error": "Invalid template index"}), 400 + + +@api.route("/api/display/apply_html/", methods=["POST"]) +@login_required +def apply_html_template(index): + uid = _cur() + if apply_custom_html(index, user_id=uid): + current_app.event_bus.publish("display_mode_changed", {"mode": "custom"}, user_id=uid) + return jsonify({"ok": True}) + return jsonify({"error": "Invalid template index"}), 400 + + +@api.route("/api/display/refresh", methods=["POST"]) +@login_required +def force_refresh_display(): + uid = _cur() + current_app.event_bus.publish("force_refresh", {}, user_id=uid) + return jsonify({"ok": True}) + + +@api.route("/api/display/kick_all", methods=["POST"]) +@admin_required +def kick_all_displays(): + """Kick ALL sessions (including all users) by bumping every user's version. + Old session cookies become invalid immediately. Also broadcasts SSE. + """ + bump_all_session_versions() + current_app.event_bus.publish("kick_all", {}) + return jsonify({"ok": True}) + + +@api.route("/api/display/notify", methods=["POST"]) +@login_required +def push_notification(): + data = request.get_json() or {} + uid = _cur() + current_app.event_bus.publish("custom_notification", { + "title": data.get("title", "提醒"), + "message": data.get("message", ""), + "icon": data.get("icon", "🔔"), + "sound": data.get("sound", True), + }, user_id=uid) + return jsonify({"ok": True}) + + +# ════════════════════════════════════════════ +# CALENDAR +# ════════════════════════════════════════════ + +@api.route("/api/calendar/groups", methods=["GET"]) +@login_required +def get_calendar_groups(): + return jsonify(calendar_mgr.get_groups(user_id=_cur())) + + +@api.route("/api/calendar/groups", methods=["POST"]) +@login_required +def create_calendar_group(): + data = request.get_json() or {} + group = calendar_mgr.create_group( + name=data.get("name", "New Group"), + color=data.get("color", "#4A90D9"), + user_id=_cur(), + ) + return jsonify(group), 201 + + +@api.route("/api/calendar/groups/", methods=["PUT"]) +@login_required +def update_calendar_group(group_id): + data = request.get_json() or {} + ok = calendar_mgr.update_group( + group_id, + name=data.get("name"), + color=data.get("color"), + enabled=data.get("enabled"), + user_id=_cur(), + ) + if not ok: + return jsonify({"error": "分组不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +@api.route("/api/calendar/groups/", methods=["DELETE"]) +@login_required +def delete_calendar_group(group_id): + ok = calendar_mgr.delete_group(group_id, user_id=_cur()) + if not ok: + return jsonify({"error": "分组不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +@api.route("/api/calendar/groups//reminder", methods=["PUT"]) +@login_required +def set_calendar_reminder(group_id): + data = request.get_json() or {} + try: + calendar_mgr.set_reminder( + group_id, + minutes_before=data.get("minutes_before", 15), + enabled=data.get("enabled", True), + user_id=_cur(), + ) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify({"ok": True}) + + +@api.route("/api/calendar/sources/upload", methods=["POST"]) +@login_required +def upload_source(): + group_id = request.form.get("group_id", type=int) + name = request.form.get("name", "uploaded") + file = request.files.get("file") + if not group_id or not file: + return jsonify({"error": "group_id and file required"}), 400 + ics_content = file.read().decode("utf-8", errors="replace") + try: + calendar_mgr.upload_ics(group_id, name, ics_content, user_id=_cur()) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify({"ok": True}), 201 + + +@api.route("/api/calendar/sources/subscribe", methods=["POST"]) +@login_required +def subscribe_source(): + data = request.get_json() or {} + group_id = data.get("group_id") + name = data.get("name", "Subscription") + url = data.get("url") + if not group_id or not url: + return jsonify({"error": "group_id and url required"}), 400 + try: + calendar_mgr.subscribe_ics(group_id, name, url, user_id=_cur()) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify({"ok": True}), 201 + + +@api.route("/api/calendar/sources/", methods=["DELETE"]) +@login_required +def delete_source(source_id): + ok = calendar_mgr.delete_source(source_id, user_id=_cur()) + if not ok: + return jsonify({"error": "来源不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +@api.route("/api/calendar/sources//sync", methods=["POST"]) +@login_required +def sync_source(source_id): + calendar_mgr.sync_subscription(source_id, user_id=_cur()) + return jsonify({"ok": True}) + + +@api.route("/api/calendar/events", methods=["GET"]) +@login_required +def get_calendar_events(): + group_ids = request.args.getlist("group_ids", type=int) or None + days = request.args.get("days", 7, type=int) + events = calendar_mgr.get_events(group_ids=group_ids, days=days, user_id=_cur()) + return jsonify(events) + + +@api.route("/api/calendar/events", methods=["POST"]) +@login_required +def create_event(): + data = request.get_json() or {} + group_id = data.get("group_id") + if not group_id: + return jsonify({"error": "group_id required"}), 400 + try: + event = calendar_mgr.add_event( + group_id=group_id, + summary=data.get("summary", ""), + dtstart=data.get("dtstart", ""), + dtend=data.get("dtend"), + description=data.get("description", ""), + location=data.get("location", ""), + is_all_day=data.get("is_all_day", False), + user_id=_cur(), + ) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify(event), 201 + + +@api.route("/api/calendar/groups//events", methods=["GET"]) +@login_required +def get_group_events(group_id): + days = request.args.get("days", 30, type=int) + events = calendar_mgr.get_events_by_group(group_id, days=days, user_id=_cur()) + return jsonify(events) + + +@api.route("/api/calendar/events/", methods=["PUT"]) +@login_required +def update_calendar_event(event_id): + data = request.get_json() or {} + ok = calendar_mgr.update_event( + event_id, + summary=data.get("summary"), + dtstart=data.get("dtstart"), + dtend=data.get("dtend"), + description=data.get("description"), + location=data.get("location"), + user_id=_cur(), + ) + if ok: + return jsonify({"ok": True}) + return jsonify({"error": "日程不存在或无权访问"}), 404 + + +@api.route("/api/calendar/events/", methods=["DELETE"]) +@login_required +def delete_calendar_event(event_id): + ok = calendar_mgr.delete_event(event_id, user_id=_cur()) + if not ok: + return jsonify({"error": "日程不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +# ════════════════════════════════════════════ +# WEATHER +# ════════════════════════════════════════════ + +@api.route("/api/weather/config", methods=["GET"]) +@login_required +def get_weather_config(): + cfg = weather_mgr.get_config(user_id=_cur()) + cfg["api_key"] = "***" if cfg.get("api_key") else "" + cfg["jwt_private_key"] = "***" if cfg.get("jwt_private_key") else "" + return jsonify(cfg) + + +@api.route("/api/weather/config", methods=["PUT"]) +@login_required +def update_weather_config(): + data = request.get_json() or {} + try: + weather_mgr.update_config( + api_key=data.get("api_key"), + city=data.get("city"), + lat=data.get("lat"), + lon=data.get("lon"), + units=data.get("units"), + update_interval=data.get("update_interval"), + credential_id=data.get("credential_id"), + project_id=data.get("project_id"), + qweather_host=data.get("qweather_host"), + user_id=_cur(), + ) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify({"ok": True}) + + +@api.route("/api/weather/current", methods=["GET"]) +@display_login_required +def get_weather(): + data = weather_mgr.get_cached_weather(user_id=_cur()) + if "error" in data and "API key" not in data.get("error", ""): + data = weather_mgr.fetch_weather(user_id=_cur()) + return jsonify(data) + + +@api.route("/api/weather/refresh", methods=["POST"]) +@login_required +def refresh_weather(): + data = weather_mgr.fetch_weather(force=True, user_id=_cur()) + return jsonify(data) + + +@api.route("/api/weather/hourly", methods=["GET"]) +@display_login_required +def get_hourly(): + data = weather_mgr.get_cached_hourly(user_id=_cur()) + if not data: + data = weather_mgr.fetch_hourly(user_id=_cur()) + return jsonify(data or []) + + +@api.route("/api/weather/hourly", methods=["POST"]) +@login_required +def refresh_hourly(): + data = weather_mgr.fetch_hourly(user_id=_cur()) + return jsonify(data or []) + + +@api.route("/api/weather/minutely", methods=["GET"]) +@display_login_required +def get_minutely(): + data = weather_mgr.get_cached_minutely(user_id=_cur()) + return jsonify(data or {}) + + +@api.route("/api/weather/minutely", methods=["POST"]) +@login_required +def refresh_minutely(): + data = weather_mgr.fetch_minutely(user_id=_cur()) + return jsonify(data) + + +@api.route("/api/weather/warnings", methods=["GET"]) +@display_login_required +def get_warnings(): + data = weather_mgr.get_cached_warnings(user_id=_cur()) + return jsonify(data or {}) + + +@api.route("/api/weather/warnings", methods=["POST"]) +@login_required +def refresh_warnings(): + data = weather_mgr.fetch_warnings(user_id=_cur()) + return jsonify(data) + + +@api.route("/api/weather/city_lookup", methods=["GET"]) +@login_required +def weather_city_lookup(): + query = request.args.get("q", "") + if not query: + return jsonify([]) + results = weather_mgr.city_lookup(query) + return jsonify(results) + + +@api.route("/api/weather/generate_keys", methods=["POST"]) +@login_required +def weather_generate_keys(): + result = weather_mgr.generate_weather_keys() + return jsonify(result) + + +# ════════════════════════════════════════════ +# TIME CONFIG +# ════════════════════════════════════════════ + +@api.route("/api/time/config", methods=["GET"]) +@login_required +def get_time_config(): + """Return current timezone configuration.""" + from backend.time_util import get_tz_str + return jsonify({ + "timezone": get_tz_str(), + }) + + +@api.route("/api/time/config", methods=["PUT"]) +@login_required +def set_time_config(): + """Update timezone configuration.""" + from backend.time_util import set_tz + data = request.get_json() or {} + tz_str = data.get("timezone", "").strip() + if not tz_str: + return jsonify({"error": "timezone is required"}), 400 + if set_tz(tz_str): + return jsonify({"ok": True}) + return jsonify({"error": f"时区 '{tz_str}' 无效,请使用 IANA 名称(如 Asia/Shanghai)或 UTC+X 格式"}), 400 + + +# ════════════════════════════════════════════ +# ALARMS +# ════════════════════════════════════════════ + +@api.route("/api/alarms", methods=["GET"]) +@login_required +def get_alarms(): + return jsonify(alarm_mgr.get_alarms(user_id=_cur())) + + +@api.route("/api/alarms", methods=["POST"]) +@login_required +def add_alarm(): + data = request.get_json() or {} + alarm = alarm_mgr.add_alarm( + label=data.get("label", ""), + time=data.get("time", "08:00"), + days=data.get("days", "1,2,3,4,5,6,7"), + enabled=data.get("enabled", True), + once=data.get("once", False), + user_id=_cur(), + ) + return jsonify(alarm), 201 + + +@api.route("/api/alarms/", methods=["PUT"]) +@login_required +def update_alarm(alarm_id): + data = request.get_json() or {} + ok = alarm_mgr.update_alarm( + alarm_id, + label=data.get("label"), + time=data.get("time"), + days=data.get("days"), + enabled=data.get("enabled"), + once=data.get("once"), + user_id=_cur(), + ) + if not ok: + return jsonify({"error": "闹钟不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +@api.route("/api/alarms/", methods=["DELETE"]) +@login_required +def delete_alarm(alarm_id): + ok = alarm_mgr.delete_alarm(alarm_id, user_id=_cur()) + if not ok: + return jsonify({"error": "闹钟不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +@api.route("/api/alarms/ringtone", methods=["POST"]) +@login_required +def upload_ringtone(): + """Upload a custom ringtone. Only audio formats allowed.""" + ALLOWED_EXT = {".mp3", ".wav", ".ogg", ".flac", ".m4a"} + ALLOWED_MIME = {"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a"} + file = request.files.get("file") + if not file: + return jsonify({"error": "file required"}), 400 + ext = os.path.splitext(file.filename or "")[1].lower() + if ext not in ALLOWED_EXT: + return jsonify({"error": f"文件类型 {ext} 不允许,仅支持 {', '.join(sorted(ALLOWED_EXT))}"}), 400 + mime = file.content_type or "" + if mime and mime not in ALLOWED_MIME: + return jsonify({"error": f"MIME 类型 {mime} 不允许"}), 400 + ringtone_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "frontend", "static", "ringtones" + ) + os.makedirs(ringtone_dir, exist_ok=True) + safe_name = "".join(c for c in file.filename if c.isalnum() or c in "._-") + if not safe_name: + safe_name = "ringtone" + ext + # Avoid overwriting another user's file with the same name (ringtones are shared). + filepath = os.path.join(ringtone_dir, safe_name) + if os.path.exists(filepath): + import uuid + safe_name = f"{os.path.splitext(safe_name)[0]}-{uuid.uuid4().hex[:6]}{ext}" + filepath = os.path.join(ringtone_dir, safe_name) + file.save(filepath) + return jsonify({"ok": True, "filename": safe_name}), 201 + + +# ════════════════════════════════════════════ +# COUNTDOWNS +# ════════════════════════════════════════════ + +@api.route("/api/countdowns", methods=["GET"]) +@login_required +def get_countdowns(): + return jsonify(countdown_mgr.get_countdowns(user_id=_cur())) + + +@api.route("/api/countdowns", methods=["POST"]) +@login_required +def add_countdown(): + data = request.get_json() or {} + cd = countdown_mgr.add_countdown( + label=data.get("label", ""), + duration_seconds=data.get("duration_seconds", 300), + enabled=data.get("enabled", True), + user_id=_cur(), + ) + return jsonify(cd), 201 + + +@api.route("/api/countdowns/", methods=["PUT"]) +@login_required +def update_countdown(cd_id): + data = request.get_json() or {} + ok = countdown_mgr.update_countdown( + cd_id, + label=data.get("label"), + duration_seconds=data.get("duration_seconds"), + enabled=data.get("enabled"), + user_id=_cur(), + ) + if not ok: + return jsonify({"error": "倒计时不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +@api.route("/api/countdowns/", methods=["DELETE"]) +@login_required +def delete_countdown(cd_id): + ok = countdown_mgr.delete_countdown(cd_id, user_id=_cur()) + if not ok: + return jsonify({"error": "倒计时不存在或无权访问"}), 404 + return jsonify({"ok": True}) + + +# ════════════════════════════════════════════ +# POMODORO +# ════════════════════════════════════════════ + +@api.route("/api/pomodoro", methods=["GET"]) +@login_required +def get_pomodoro(): + return jsonify(pomodoro_mgr.get_state(user_id=_cur())) + + +@api.route("/api/pomodoro", methods=["POST"]) +@login_required +def create_pomodoro(): + data = request.get_json() or {} + state = pomodoro_mgr.create_or_update( + work_minutes=data.get("work_minutes"), + break_minutes=data.get("break_minutes"), + long_break_minutes=data.get("long_break_minutes"), + cycles_before_long_break=data.get("cycles_before_long_break"), + enabled=data.get("enabled"), + display_controls_enabled=data.get("display_controls_enabled"), + work_label=data.get("work_label"), + break_label=data.get("break_label"), + user_id=_cur(), + ) + return jsonify(state), 201 + + +@api.route("/api/pomodoro/start", methods=["POST"]) +@login_required +def start_pomodoro(): + return jsonify(pomodoro_mgr.start(user_id=_cur())) + + +@api.route("/api/pomodoro/pause", methods=["POST"]) +@login_required +def pause_pomodoro(): + return jsonify(pomodoro_mgr.pause(user_id=_cur())) + + +@api.route("/api/pomodoro/stop", methods=["POST"]) +@login_required +def stop_pomodoro(): + return jsonify(pomodoro_mgr.stop(user_id=_cur())) + + +@api.route("/api/pomodoro/skip", methods=["POST"]) +@login_required +def skip_pomodoro(): + return jsonify(pomodoro_mgr.skip(user_id=_cur())) + + +@api.route("/api/pomodoro/dismiss", methods=["POST"]) +@login_required +def dismiss_pomodoro(): + return jsonify(pomodoro_mgr.dismiss(user_id=_cur())) + + +# ─── Register Blueprint ────────────────────── + +def register_routes(app): + app.register_blueprint(api) diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..4a9278f --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,86 @@ +"""Background scheduler for periodic tasks. + +All user-scoped jobs iterate over every active user (with that user's +timezone/context active) so each user's alarms, reminders, calendars, +pomodoro, countdowns and weather are serviced independently. +""" +from apscheduler.schedulers.background import BackgroundScheduler +from backend.calendar_mgr import sync_all_subscriptions, get_upcoming_reminders +from backend.weather_mgr import fetch_weather, fetch_hourly +from backend.alarm_mgr import check_alarms +from backend.countdown_mgr import check_countdowns +from backend.pomodoro_mgr import check_and_trigger +from backend.models import get_db +from backend.time_util import set_thread_user, clear_thread_user + + +_scheduler: BackgroundScheduler | None = None + + +def _each_user(app, fn): + """Run fn(user_id) for every active user inside the app context.""" + with app.app_context(): + db = get_db(app.config.get("DB_PATH", "data.db")) + rows = db.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall() + db.close() + for u in rows: + uid = u["id"] + try: + set_thread_user(uid) + fn(uid) + except Exception as e: + app.logger.error(f"Scheduler error for user {uid}: {e}") + finally: + clear_thread_user() + + +def start_scheduler(app): + global _scheduler + _scheduler = BackgroundScheduler() + + @_scheduler.scheduled_job("interval", seconds=10, id="check_alarms") + def _check_alarms(): + _each_user(app, check_alarms) + + @_scheduler.scheduled_job("interval", minutes=1, id="check_reminders") + def _check_reminders(): + def _run(uid): + due = get_upcoming_reminders(uid) + if due: + app.event_bus.publish("reminder_due", {"events": due}, user_id=uid) + app.logger.info(f"Reminders due for user {uid}: {len(due)} events") + _each_user(app, _run) + + @_scheduler.scheduled_job("interval", minutes=30, id="sync_calendars") + def _sync_calendars(): + with app.app_context(): + sync_all_subscriptions() + + @_scheduler.scheduled_job("cron", minute="0,30", id="fetch_weather") + def _fetch_weather(): + _each_user(app, fetch_weather) + + @_scheduler.scheduled_job("interval", seconds=1, id="check_pomodoro") + def _check_pomodoro(): + _each_user(app, check_and_trigger) + + @_scheduler.scheduled_job("cron", minute="1", id="fetch_hourly") + def _fetch_hourly(): + _each_user(app, fetch_hourly) + + @_scheduler.scheduled_job("interval", seconds=1, id="check_countdowns") + def _check_countdowns(): + def _run(uid): + triggered = check_countdowns(uid) + if triggered: + app.logger.info(f"Countdowns triggered for user {uid}: {[c['label'] for c in triggered]}") + _each_user(app, _run) + + _scheduler.start() + + +def stop_scheduler(): + global _scheduler + if _scheduler: + _scheduler.shutdown(wait=False) + _scheduler = None diff --git a/backend/sun_util.py b/backend/sun_util.py new file mode 100644 index 0000000..1942dc5 --- /dev/null +++ b/backend/sun_util.py @@ -0,0 +1,114 @@ +"""日出日落时间计算(NOAA 简化算法)与白天/夜晚判定。 + +用于"自动"主题模式:根据用户天气配置的经纬度 + 本地时区,判断当前 +时刻处于白天(浅色主题)还是夜晚(深色主题)。 + +算法来源:Ed Williams《Sunrise/Sunset Algorithm》 +(https://edwilliams.org/sunrise_sunset_algorithm.htm) +""" +import math +from datetime import datetime, timezone, timedelta + +# 太阳天顶角:日出日落定义(含大气折射) +ZENITH = 90.833 + + +def _day_of_year(dt: datetime) -> int: + """计算一年中的第几天(NOAA 公式)。""" + y, m, d = dt.year, dt.month, dt.day + n1 = math.floor(275 * m / 9) + n2 = math.floor((m + 9) / 12) + n3 = 1 + math.floor((y - 4 * math.floor(y / 4) + 2) / 3) + return n1 - (n2 * n3) + d - 30 + + +def _hour_angle(lat: float, lon: float, day_of_year: int, is_sunrise: bool) -> float | None: + """返回日出/日落的 UTC 小时(0~24),None 表示极昼/极夜(当天无日出日落)。""" + lng_hour = lon / 15.0 + if is_sunrise: + t = day_of_year + ((6 - lng_hour) / 24.0) + else: + t = day_of_year + ((18 - lng_hour) / 24.0) + + m = (0.9856 * t) - 3.289 + l = (m + 1.916 * math.sin(math.radians(m)) + + 0.020 * math.sin(math.radians(2 * m)) + 282.634) % 360 + + ra = math.degrees(math.atan(0.91764 * math.tan(math.radians(l)))) % 360 + # 校正 RA 象限 + l_quad = math.floor(l / 90) * 90 + ra_quad = math.floor(ra / 90) * 90 + ra = ra + (l_quad - ra_quad) + ra /= 15.0 # 转为小时 + + sin_dec = 0.39782 * math.sin(math.radians(l)) + cos_dec = math.cos(math.asin(sin_dec)) + cos_h = ((math.cos(math.radians(ZENITH)) - sin_dec * math.sin(math.radians(lat))) + / (cos_dec * math.cos(math.radians(lat)))) + + if cos_h > 1 or cos_h < -1: + return None # 极昼/极夜 + + if is_sunrise: + h = 360 - math.degrees(math.acos(cos_h)) + else: + h = math.degrees(math.acos(cos_h)) + h /= 15.0 + + # NOAA:T = H + RA - 0.06571*t - 6.622(小时),UT = T - lngHour(mod 24) + local_t = h + ra - 0.06571 * t - 6.622 + return (local_t - lng_hour) % 24.0 + + +def sun_times_utc(lat: float, lon: float, date: datetime) -> tuple[datetime | None, datetime | None]: + """返回 (日出UTC, 日落UTC),极昼/极夜时对应项为 None。 + date 为任意带时区 datetime(取其日期)。""" + lat = float(lat) + lon = float(lon) + day = _day_of_year(date.astimezone(timezone.utc)) + + sunrise_h = _hour_angle(lat, lon, day, True) + sunset_h = _hour_angle(lat, lon, day, False) + if sunrise_h is None and sunset_h is None: + return None, None # 极昼或极夜,调用方按本地小时粗判 + + def to_utc(h: float) -> datetime: + base = date.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + return base + timedelta(hours=h) + + sunrise = to_utc(sunrise_h) if sunrise_h is not None else None + sunset = to_utc(sunset_h) if sunset_h is not None else None + return sunrise, sunset + + +def is_daytime(lat: float, lon: float, now_local: datetime) -> bool: + """判断本地时刻是否为白天。 + now_local 为带时区的本地 datetime(使用用户配置时区)。 + + 用 UTC 小时比较(忽略日期归属),自动处理日出/日落跨 UTC 午夜的情况。 + """ + now_utc = now_local.astimezone(timezone.utc) + sunrise, sunset = sun_times_utc(lat, lon, now_local) + + if sunrise is None or sunset is None: + # 极昼/极夜:根据本地小时粗判(6-18 视为白天) + return 6 <= now_local.hour < 18 + + sr_h = sunrise.hour + sunrise.minute / 60.0 + ss_h = sunset.hour + sunset.minute / 60.0 + now_h = now_utc.hour + now_utc.minute / 60.0 + + if ss_h < sr_h: + ss_h += 24.0 # 跨 UTC 午夜(东半球) + if now_h < sr_h: + now_h += 24.0 + return sr_h <= now_h <= ss_h + + +def day_fraction_today(lat: float, lon: float, now_local: datetime) -> float: + """当天白昼占全天比例(0~1),用于自动模式的平滑切换(预留)。""" + sunrise, sunset = sun_times_utc(lat, lon, now_local) + if sunrise is None or sunset is None: + return 0.5 + total = (sunset - sunrise).total_seconds() + return max(0.0, min(1.0, total / 86400.0)) diff --git a/backend/time_util.py b/backend/time_util.py new file mode 100644 index 0000000..bcf4b32 --- /dev/null +++ b/backend/time_util.py @@ -0,0 +1,267 @@ +"""Timezone-aware datetime utilities. + +Central source for all local-time operations in the project. +Reads the configured timezone from clock_config table (default: Asia/Shanghai). +All backend modules should use these functions instead of bare datetime.now(). + +Two time domains: + - **Local time** (configurable offset) — for date boundaries, display timestamps, + user-facing schedule logic. Use now(), today(), strftime(), strptime(). + - **Unix timestamps** (UTC, always) — for interval math, expiry, cooldowns. + Use ts_now(), ts_from_local(), ts_to_local(). + +The timezone is cached per-request via Flask app config to avoid DB lookups. +""" +import time as _time +import threading +from datetime import datetime, timedelta, timezone, date +from typing import Union + +import pytz + +# Thread-local "current user" context (set by the scheduler while it processes +# a specific user's job; request threads resolve the user from the session). +_TLS = threading.local() + + +def set_thread_user(user_id): + """Set the current user for this thread (scheduler per-user iteration).""" + _TLS.user_id = user_id + + +def clear_thread_user(): + try: + del _TLS.user_id + except AttributeError: + pass + + +def get_thread_user(): + return getattr(_TLS, "user_id", None) + + +def _ctx_user_id(): + """Resolve the current user id: request session first, then thread-local.""" + try: + from flask import has_request_context, session + if has_request_context(): + uid = session.get("user_id") + if uid is not None: + return int(uid) + except Exception: + pass + return getattr(_TLS, "user_id", None) or 0 + +# ─── Timezone loading ─────────────────────── + +_DEFAULT_TZ_STR = "Asia/Shanghai" +_DEFAULT_UTC_OFFSET = timezone(timedelta(hours=8)) + + +def _load_tz() -> "pytz.BaseTzInfo": + """Load timezone from the current user's clock_config. Falls back to Asia/Shanghai.""" + try: + from flask import current_app + db_path = current_app.config.get("DB_PATH", "data.db") + from backend.models import get_db + db = get_db(db_path) + row = db.execute( + "SELECT timezone FROM clock_config WHERE is_primary=1 AND user_id=?", + (_ctx_user_id(),) + ).fetchone() + db.close() + if row and row["timezone"]: + tz_str = row["timezone"].strip() + return pytz.timezone(tz_str) + except Exception: + pass + return pytz.timezone(_DEFAULT_TZ_STR) + + +def get_tz() -> "pytz.BaseTzInfo": + """Return the configured timezone object. Cached per user in app config.""" + try: + from flask import current_app + uid = _ctx_user_id() + if hasattr(current_app, "config") and "_timezone_cache" in current_app.config: + cache = current_app.config["_timezone_cache"] + if uid in cache: + return cache[uid] + tz = _load_tz() + if hasattr(current_app, "config"): + cache = current_app.config.setdefault("_timezone_cache", {}) + cache[uid] = tz + return tz + except Exception: + return pytz.timezone(_DEFAULT_TZ_STR) + + +def clear_tz_cache(): + """Force reload timezone on next call. Call after timezone config changes.""" + try: + from flask import current_app + current_app.config.pop("_timezone_cache", None) + except Exception: + pass + + +def set_tz(tz_str: str) -> bool: + """Set the timezone in clock_config DB and clear cache. + + Args: + tz_str: IANA timezone name (e.g. "Asia/Shanghai", "America/New_York") + or UTC offset string (e.g. "UTC+8", "UTC-5") + + Returns: + True on success, False on invalid timezone. + """ + # Normalize "UTC+X" / "UTC-X" to pytz-compatible + normalized = tz_str.strip() + if normalized.upper().startswith("UTC") or normalized.upper().startswith("UT"): + try: + cleaned = normalized.upper().replace("UTC", "").replace("UT", "").strip() + sign = 1 + if cleaned.startswith("+"): + cleaned = cleaned[1:] + elif cleaned.startswith("-"): + sign = -1 + cleaned = cleaned[1:] + hours = int(cleaned) + # Convert to an IANA-like name: pytz has FixedOffset but easiest is + # just use a fixed-offset timezone + import pytz + tz = pytz.FixedOffset(sign * hours * 60) # FixedOffset expects minutes + except (ValueError, IndexError): + return False + else: + # IANA name — validate by trying to load + try: + import pytz + tz = pytz.timezone(normalized) + except Exception: + return False + + # Save to DB + try: + from flask import current_app + db_path = current_app.config.get("DB_PATH", "data.db") + from backend.models import get_db + db = get_db(db_path) + uid = _ctx_user_id() + db.execute( + "UPDATE clock_config SET timezone=? WHERE is_primary=1 AND user_id=?", + (normalized, uid) + ) + if db.total_changes == 0: + db.execute( + "INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', ?, 1)", + (uid, normalized) + ) + db.commit() + db.close() + clear_tz_cache() + return True + except Exception: + return False + + +def get_tz_str() -> str: + """Return the configured timezone as a string.""" + try: + from flask import current_app + db_path = current_app.config.get("DB_PATH", "data.db") + from backend.models import get_db + db = get_db(db_path) + row = db.execute( + "SELECT timezone FROM clock_config WHERE is_primary=1 AND user_id=?", + (_ctx_user_id(),) + ).fetchone() + db.close() + if row and row["timezone"]: + return row["timezone"] + except Exception: + pass + return _DEFAULT_TZ_STR + + +# ─── Local time functions ─────────────────── +# Use these for date boundaries, display timestamps, schedule logic. + +def now() -> datetime: + """Return current datetime in the configured timezone. + + This is the primary replacement for bare `datetime.now()`. + """ + return datetime.now(get_tz()) + + +def today() -> date: + """Return current date in the configured timezone.""" + return now().date() + + +def today_str(fmt: str = "%Y%m%d") -> str: + """Return today's date formatted as string. + + Common format: "%Y%m%d" for DB comparisons, "%Y-%m-%d" for displays. + """ + return now().strftime(fmt) + + +def strftime(fmt: str = "%Y-%m-%d %H:%M:%S") -> str: + """Return current datetime formatted as string in local timezone.""" + return now().strftime(fmt) + + +def strptime(date_string: str, fmt: str) -> datetime: + """Parse a string into a timezone-aware datetime. + + The result is in the configured timezone. If the input string + has no timezone info, it's assumed to be in local time. + """ + dt = datetime.strptime(date_string, fmt) + return get_tz().localize(dt) + + +def isoformat() -> str: + """Return current time in ISO 8601 format with timezone offset.""" + return now().isoformat() + + +def localize(dt: datetime) -> datetime: + """Convert a naive datetime to timezone-aware in configured timezone. + + If dt is already timezone-aware, it's converted to local timezone. + """ + tz = get_tz() + if dt.tzinfo is None: + return tz.localize(dt) + return dt.astimezone(tz) + + +# ─── Unix timestamp utilities ─────────────── +# These are timezone-independent — always UTC for epoch math. + +def ts_now() -> float: + """Return current Unix timestamp (seconds since epoch, UTC). + + Equivalent to time.time(). Use for expiry, cooldowns, interval math. + """ + return _time.time() + + +def ts_from_local(dt: datetime = None) -> float: + """Convert a local-timezone datetime to Unix timestamp. + + If dt is None, returns current timestamp (same as ts_now()). + """ + if dt is None: + return _time.time() + if dt.tzinfo is None: + dt = localize(dt) + return dt.timestamp() + + +def ts_to_local(ts: float) -> datetime: + """Convert a Unix timestamp to local timezone datetime.""" + return datetime.fromtimestamp(ts, get_tz()) diff --git a/backend/url_util.py b/backend/url_util.py new file mode 100644 index 0000000..8015b6b --- /dev/null +++ b/backend/url_util.py @@ -0,0 +1,86 @@ +"""URL safety helpers — prevent SSRF from user-supplied fetch URLs +(calendar subscriptions, weather API hosts).""" +import ipaddress +import socket +from urllib.parse import urlparse + +ALLOWED_SCHEMES = ("http", "https") + +# Hosts that are always fine to reach (QWeather SaaS / common cloud APIs). +# Everything else is resolved and must be a public address. +_ALWAYS_ALLOWED = { + "devapi.qweather.com", + "geoapi.qweather.com", + "qweather.com", +} + + +def _host_is_private(hostname: str) -> bool: + """Resolve a hostname and return True if ANY address is non-public.""" + try: + infos = socket.getaddrinfo(hostname, None) + except Exception: + # Unresolvable — treat as unsafe (fail closed for fetches). + return True + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if (ip.is_private or ip.is_loopback or ip.is_link_local + or ip.is_multicast or ip.is_reserved or ip.is_unspecified): + return True + return False + + +def is_safe_public_url(url: str) -> bool: + """True if the URL is http(s) and resolves to public addresses only.""" + if not url or not isinstance(url, str): + return False + try: + p = urlparse(url.strip()) + except Exception: + return False + if p.scheme not in ALLOWED_SCHEMES or not p.hostname: + return False + host = p.hostname.lower().rstrip(".") + if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"): + return True + if p.port and p.port not in (80, 443): + return False + return not _host_is_private(host) + + +def is_safe_https_host(host: str) -> bool: + """True if the value is a bare https hostname (no scheme/path) and public.""" + if not host or not isinstance(host, str): + return False + host = host.strip().lower().rstrip(".") + if not host or "://" in host or "/" in host or " " in host: + return False + if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"): + return True + return not _host_is_private(host) + + +def safe_http_get(url: str, timeout: float = 15, max_redirects: int = 3): + """GET a URL, re-validating safety on every redirect hop. + + Returns the final `requests.Response` (status 200) or None if any hop is + unsafe/unresolvable or the redirect chain exceeds max_redirects. + Raises requests.HTTPError for non-2xx final responses. + """ + import requests + from urllib.parse import urljoin + + current = (url or "").strip() + for _ in range(max_redirects + 1): + if not is_safe_public_url(current): + return None + resp = requests.get(current, timeout=timeout, allow_redirects=False) + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("Location", "") + if not location: + return None + current = urljoin(current, location) + continue + resp.raise_for_status() + return resp + return None diff --git a/backend/weather_mgr.py b/backend/weather_mgr.py new file mode 100644 index 0000000..fe7830f --- /dev/null +++ b/backend/weather_mgr.py @@ -0,0 +1,680 @@ +"""QWeather client: JWT auth, real-time weather, city search, intelligent caching.""" +import json +import requests +from datetime import datetime +from backend.time_util import now as tz_now, strftime as tz_strftime, today_str as tz_today_str, strptime as tz_strptime, localize as tz_localize +from flask import current_app +from backend.models import get_db +from backend.jwt_util import sign_jwt, generate_key_pair + +# QWeather API host (shared dev host, or user's dedicated host) +QWEATHER_API = "https://devapi.qweather.com" +QWEATHER_GEO_API = "https://geoapi.qweather.com" + +# Cache TTL in seconds (default 30 min) +CACHE_TTL = 1800 + + +# ─── Config ──────────────────────────────── + +def _uid() -> int: + """Resolve the current user (request session or scheduler thread).""" + try: + from backend.auth import current_user_id + return current_user_id() or 0 + except Exception: + return 0 + + +def get_config(user_id: int = None) -> dict: + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute("SELECT * FROM weather_config WHERE user_id=?", (user_id,)).fetchone() + db.close() + return dict(row) if row else {"user_id": user_id} + + +def update_config(api_key: str = None, + city: str = None, lat: float = None, lon: float = None, + units: str = None, update_interval: int = None, + credential_id: str = None, project_id: str = None, + qweather_host: str = None, user_id: int = None): + if user_id is None: + user_id = _uid() + if qweather_host is not None and qweather_host.strip(): + from backend.url_util import is_safe_https_host + if not is_safe_https_host(qweather_host): + raise ValueError("API Host 无效或不安全:仅允许公网域名(如 abc123.def.qweatherapi.com)") + db = get_db(current_app.config["DB_PATH"]) + fields = [] + vals = [] + for key, val in [ + ("api_key", api_key), + ("city", city), ("lat", lat), ("lon", lon), + ("units", units), ("update_interval", update_interval), + ("credential_id", credential_id), ("project_id", project_id), + ("qweather_host", qweather_host), + ]: + if val is not None: + fields.append(f"{key}=?") + vals.append(val) + row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone() + if row is None: + db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,)) + if fields: + vals.append(user_id) + db.execute(f"UPDATE weather_config SET {', '.join(fields)} WHERE user_id=?", vals) + db.commit() + db.close() + + +def generate_weather_keys() -> dict: + """Generate new Ed25519 key pair for QWeather JWT auth. + + Returns: + dict with public_key (private key stored in DB) + """ + priv, pub = generate_key_pair() + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone() + if row is None: + db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,)) + db.execute( + "UPDATE weather_config SET jwt_private_key=?, jwt_public_key=? WHERE user_id=?", + (priv, pub, user_id) + ) + db.commit() + db.close() + return {"public_key": pub} + + +# ─── JWT Token ───────────────────────────── + +def _get_auth_header(cfg: dict) -> dict: + """Build the Authorization header for QWeather API. + + 仅和风天气。优先 JWT(credential_id + project_id + jwt_private_key), + 否则回退到和风旧版 API Key(X-QW-Api-Key)。 + """ + priv_key = cfg.get("jwt_private_key", "") + kid = cfg.get("credential_id", "") + sub = cfg.get("project_id", "") + + if priv_key and kid and sub: + token = sign_jwt(priv_key, kid, sub) + return {"Authorization": f"Bearer {token}"} + + api_key = cfg.get("api_key", "") + if api_key: + return {"X-QW-Api-Key": api_key} + + return {} + + +# ─── API Host ────────────────────────────── + +def _api_host(cfg: dict) -> str: + host = cfg.get("qweather_host", "").strip() + from backend.url_util import is_safe_https_host + if host and is_safe_https_host(host): + return f"https://{host}" + return QWEATHER_API + + +def _geo_host(cfg: dict) -> str: + # If user has a dedicated host, GeoAPI is under the same host + host = cfg.get("qweather_host", "").strip() + from backend.url_util import is_safe_https_host + if host and is_safe_https_host(host): + return f"https://{host}" + return QWEATHER_GEO_API + + +# ─── City Lookup ─────────────────────────── + +def city_lookup(query: str, number: int = 10) -> list: + """Search cities via QWeather GeoAPI. + + Returns list of {name, id, lat, lon, adm1, adm2, country} + """ + cfg = get_config() + headers = _get_auth_header(cfg) + if not headers: + return [] + + host = _geo_host(cfg) + url = f"{host}/v2/city/lookup" + params = { + "location": query, + "number": min(number, 20), + "lang": "zh", + } + + try: + resp = requests.get(url, params=params, headers=headers, timeout=8) + if resp.status_code != 200: + current_app.logger.error(f"City lookup failed: {resp.status_code} {resp.text}") + return [] + data = resp.json() + if data.get("code") != "200": + return [] + results = [] + for loc in data.get("location", []): + results.append({ + "name": loc.get("name", ""), + "id": loc.get("id", ""), + "lat": loc.get("lat", ""), + "lon": loc.get("lon", ""), + "adm1": loc.get("adm1", ""), + "adm2": loc.get("adm2", ""), + "country": loc.get("country", ""), + }) + return results + except Exception as e: + current_app.logger.error(f"City lookup error: {e}") + return [] + + +# ─── Weather Fetch ───────────────────────── + +def fetch_weather(force: bool = False, user_id: int = None) -> dict: + """Fetch real-time weather from QWeather and cache it. + + Args: + force: If True, skip fallback cache on error — return error directly. + """ + if user_id is None: + user_id = _uid() + return _fetch_qweather(get_config(user_id), force=force, user_id=user_id) + + +def _fetch_qweather(cfg: dict, force: bool = False, user_id: int = None) -> dict: + """Fetch weather from QWeather v7/weather/now.""" + headers = _get_auth_header(cfg) + if not headers: + return {"error": "未配置 JWT 密钥或 API Key", "city": cfg.get("city", "")} + + lat = cfg.get("lat") or 39.9042 + lon = cfg.get("lon") or 116.4074 + host = _api_host(cfg) + + url = f"{host}/v7/weather/now" + params = { + "location": f"{lon:.2f},{lat:.2f}", + "lang": "zh", + } + + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.status_code == 401: + current_app.logger.error("QWeather: JWT auth failed (401)") + return {"error": "JWT 认证失败,请检查密钥配置", "city": cfg.get("city", "")} + if resp.status_code == 403: + current_app.logger.error(f"QWeather: forbidden (403) body={resp.text[:200]}") + # Detect Invalid Host (shared dev domain deprecated) + if "invalid host" in resp.text.lower() or "unauthorized api host" in resp.text.lower(): + return {"error": "API Host 无效:共享开发域名已弃用,请在控制端配置专属 API Host(和风控制台 → 产品 → API Host)", "city": cfg.get("city", "")} + return {"error": "请求被拒绝(403),请检查额度、权限或 API Host 配置", "city": cfg.get("city", "")} + if resp.status_code == 429: + msg = "请求太频繁,请稍后再试" + current_app.logger.warning(f"QWeather: rate limited (429)") + return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id) + resp.raise_for_status() + data = resp.json() + if data.get("code") != "200": + err_msg = data.get("code", "unknown") + current_app.logger.error(f"QWeather API error code: {err_msg}") + return {"error": f"API 错误: {err_msg}", "city": cfg.get("city", "")} + except requests.exceptions.Timeout: + msg = "API 请求超时" + current_app.logger.error(f"QWeather: timeout") + return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id) + except requests.exceptions.ConnectionError: + msg = "无法连接 API" + current_app.logger.error(f"QWeather: connection failed") + return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id) + except Exception as e: + msg = str(e) + current_app.logger.error(f"QWeather error: {e}") + return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id) + + now_data = data.get("now", {}) + + # Map QWeather icon codes to something displayable + icon_code = now_data.get("icon", "999") + + result = { + "city": cfg.get("city", ""), + "temp": float(now_data.get("temp", 0)), + "feels_like": float(now_data.get("feelsLike", 0)), + "humidity": int(now_data.get("humidity", 0)), + "pressure": int(now_data.get("pressure", 0)), + "description": now_data.get("text", ""), + "icon": icon_code, + "wind_speed": float(now_data.get("windSpeed", 0)), + "wind_dir": now_data.get("windDir", ""), + "wind_scale": int(now_data.get("windScale", 0)), + "visibility": now_data.get("vis", ""), + "precip": now_data.get("precip", "0"), + "cloud": now_data.get("cloud", ""), + "dew": now_data.get("dew", ""), + "obs_time": now_data.get("obsTime", ""), + "updated_at": tz_now().isoformat(), + "provider": "qweather", + } + + _save_cache(result, user_id) + current_app.event_bus.publish("weather_updated", result, user_id=user_id) + return result + + + +# ─── Cache ───────────────────────────────── + +def _save_cache(data: dict, user_id: int = None): + """Write weather data to the cache table, replacing any existing row.""" + if user_id is None: + user_id = _uid() + now_str = tz_strftime() + db = get_db(current_app.config["DB_PATH"]) + db.execute("DELETE FROM weather_cache WHERE user_id=?", (user_id,)) + db.execute( + "INSERT INTO weather_cache (user_id, data, updated_at) VALUES (?, ?, ?)", + (user_id, json.dumps(data), now_str) + ) + db.commit() + db.close() + + +def _fallback_cache(cfg: dict, user_id: int = None) -> dict: + """Return cached data if available, else error.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + cache = db.execute( + "SELECT data FROM weather_cache WHERE user_id=?", (user_id,) + ).fetchone() + db.close() + if cache: + cached = json.loads(cache["data"]) + cached["cached"] = True + return cached + return {"error": "无法获取天气数据", "city": cfg.get("city", "")} + + +def get_cached_weather(user_id: int = None) -> dict: + """Return cached weather or empty error.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute("SELECT * FROM weather_cache WHERE user_id=?", (user_id,)).fetchone() + db.close() + if row: + return json.loads(row["data"]) + return {"error": "暂无天气数据"} + + +# ─── Next Update Schedule ───────────────── + +def get_next_update_times(user_id: int = None) -> dict: + """Compute next scheduled update times based on cron rules. + + - Weather (current): every :00 and :30 + - Hourly forecast: every :01 past the hour + """ + from datetime import timedelta + if user_id is None: + user_id = _uid() + now = tz_now() + + # Next weather update: next :00 or :30 + if now.minute < 30: + next_w = now.replace(minute=30, second=0, microsecond=0) + else: + next_w = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + + # Next hourly update: next :01 + if now.minute < 1: + next_h = now.replace(minute=1, second=0, microsecond=0) + else: + next_h = (now + timedelta(hours=1)).replace(minute=1, second=0, microsecond=0) + + # Read last update times from cached JSON (local time) not SQLite datetime('now') which is UTC + db = get_db(current_app.config["DB_PATH"]) + + w_updated = None + w_row = db.execute("SELECT data FROM weather_cache WHERE user_id=?", (user_id,)).fetchone() + if w_row and w_row["data"]: + w_data = json.loads(w_row["data"]) + w_updated = w_data.get("updated_at", None) + + h_updated = None + h_row = db.execute("SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone() + if h_row and h_row["data"]: + h_data = json.loads(h_row["data"]) + # Hourly data is a list; we store fetched_at separately, but for display + # use the SQLite fetched_at (converted) or just show the schedule + # Let's use the local-computed schedule as last-update signal + h_row2 = db.execute("SELECT fetched_at FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone() + if h_row2 and h_row2[0]: + # Keep the UTC string but note it's UTC — we'll just pass it + h_updated = h_row2[0] + + db.close() + + return { + "weather_updated_at": w_updated, + "weather_next_update": next_w.strftime("%Y-%m-%d %H:%M:%S"), + "hourly_updated_at": h_updated, + "hourly_next_update": next_h.strftime("%Y-%m-%d %H:%M:%S"), + } + + +# ─── Hourly Forecast ────────────────────── + +def fetch_hourly(user_id: int = None) -> list: + """Fetch 24-hour forecast from QWeather, filter past hours, cache. + + Returns filtered list of {time, temp, icon, text, pop, windSpeed, windDir}. + Handles cross-hour/cross-day: filters out fxTime < now, so stale cache + never shows expired hours. If filtered result is short, caller should + re-fetch (handled by scheduler every 60 min). + """ + if user_id is None: + user_id = _uid() + cfg = get_config(user_id) + + headers = _get_auth_header(cfg) + if not headers: + return [] + + lat = cfg.get("lat") or 39.9042 + lon = cfg.get("lon") or 116.4074 + host = _api_host(cfg) + url = f"{host}/v7/weather/24h" + params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"} + + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.status_code != 200: + current_app.logger.error(f"Hourly fetch failed: {resp.status_code}") + return _get_cached_hourly(user_id) or [] + data = resp.json() + if data.get("code") != "200": + return _get_cached_hourly(user_id) or [] + raw_list = data.get("hourly", []) + except Exception as e: + current_app.logger.error(f"Hourly fetch error: {e}") + return _get_cached_hourly(user_id) or [] + + now = tz_now() + today_date = now.strftime("%Y-%m-%d") + filtered = [] + for h in raw_list: + fx_str = h.get("fxTime", "") + fx_time = _parse_qw_time(fx_str) + if not fx_time: + continue + # Unify to a local timezone-aware datetime via the time middleware. + # fxTime from QWeather may carry an offset (+08:00) or be naive; + # never strip tzinfo — that breaks comparison with tz_now(). + fx_local = tz_localize(fx_time) + if fx_local >= now: + # Determine if this hour is today or tomorrow + fx_date = fx_local.strftime("%Y-%m-%d") + day_label = "明日" if fx_date > today_date else "今日" + filtered.append({ + "time": fx_local.strftime("%H:%M"), + "day": day_label, + "temp": h.get("temp", ""), + "icon": h.get("icon", "999"), + "text": h.get("text", ""), + "pop": h.get("pop", "0"), + "wind_speed": h.get("windSpeed", "0"), + "wind_dir": h.get("windDir", ""), + }) + + _save_hourly_cache(filtered, user_id) + current_app.logger.info(f"Hourly: fetched {len(raw_list)} → {len(filtered)} future hours") + return filtered + + +def _parse_qw_time(s: str): + """Parse QWeather ISO time string to datetime, or None.""" + if not s: + return None + try: + # Format: "2026-07-14T15:00+08:00" or "2026-07-14T15:00+08:00" + return datetime.fromisoformat(s) + except Exception: + pass + try: + # Fallback: "2026-07-14T15:00" + return datetime.strptime(s[:16], "%Y-%m-%dT%H:%M") + except Exception: + return None + + +def get_cached_hourly(user_id: int = None) -> list: + """Return cached hourly forecast with cross-time filtering. + + Filters out past hours using proper time comparison (handles + cross-midnight correctly). If fewer than 12 remain, logs a warning + so the scheduler knows to refresh early. + """ + if user_id is None: + user_id = _uid() + data = _get_cached_hourly(user_id) + if not data: + return [] + + # Detect old cache format (missing "day" field) — force re-fetch + if not data[0].get("day"): + current_app.logger.info("Hourly: old cache format detected, force re-fetch") + return [] + + now = tz_now() + now_minutes = now.hour * 60 + now.minute + + valid = [] + for h in data: + parts = h.get("time", "00:00").split(":") + h_minutes = int(parts[0]) * 60 + int(parts[1]) + day = h.get("day", "今日") + # Keep if: it's "今日" and time hasn't passed, OR it's "明日" (always future) + if (day == "今日" and h_minutes >= now_minutes) or day == "明日": + valid.append(h) + + if len(valid) < 12: + current_app.logger.info( + f"Hourly cache: only {len(valid)} future hours left, will refresh soon" + ) + return valid + + +def _save_hourly_cache(data: list, user_id: int = None): + """Persist hourly data, replacing old cache.""" + if user_id is None: + user_id = _uid() + now_str = tz_strftime() + db = get_db(current_app.config["DB_PATH"]) + db.execute( + "DELETE FROM weather_hourly_cache WHERE user_id=?", (user_id,) + ) + db.execute( + "INSERT INTO weather_hourly_cache (user_id, data, fetched_at) VALUES (?, ?, ?)", + (user_id, json.dumps(data), now_str) + ) + db.commit() + db.close() + + +def _get_cached_hourly(user_id: int = None) -> list | None: + """Read cached hourly data from DB.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute( + "SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,) + ).fetchone() + db.close() + if row and row["data"]: + return json.loads(row["data"]) + return None + + +# ─── Minutely Forecast (manual, rate-limited) ─ + +_MINUTELY_COOLDOWN = 180 # 3 minutes in seconds + +def fetch_minutely(user_id: int = None) -> dict: + """Fetch minute-level precipitation forecast from QWeather v7/minutely/5m. + + Rate-limited: once per 3 minutes. Returns cooldown info on rejection. + """ + if user_id is None: + user_id = _uid() + # Check cooldown + db = get_db(current_app.config["DB_PATH"]) + row = db.execute( + "SELECT fetched_at FROM weather_minutely_cache WHERE user_id=?", (user_id,) + ).fetchone() + if row and row["fetched_at"]: + try: + last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S") + elapsed = (tz_now() - last).total_seconds() + if elapsed < _MINUTELY_COOLDOWN: + remaining = int(_MINUTELY_COOLDOWN - elapsed) + db.close() + return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining} + except Exception: + pass + db.close() + + cfg = get_config(user_id) + headers = _get_auth_header(cfg) + if not headers: + return {"error": "未配置认证"} + + lat = cfg.get("lat") or 39.9042 + lon = cfg.get("lon") or 116.4074 + host = _api_host(cfg) + url = f"{host}/v7/minutely/5m" + params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"} + + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.status_code != 200: + return {"error": f"API 返回 {resp.status_code}"} + data = resp.json() + if data.get("code") != "200": + return {"error": f"API 错误: {data.get('code')}"} + except Exception as e: + return {"error": str(e)} + + now_str = tz_strftime() + result = { + "summary": data.get("summary", ""), + "minutely": data.get("minutely", []), + "fetched_at": now_str, + } + db = get_db(current_app.config["DB_PATH"]) + db.execute("DELETE FROM weather_minutely_cache WHERE user_id=?", (user_id,)) + db.execute( + "INSERT INTO weather_minutely_cache (user_id, data, fetched_at) VALUES (?, ?, ?)", + (user_id, json.dumps(result), now_str) + ) + db.commit() + db.close() + return result + + +def get_cached_minutely(user_id: int = None) -> dict | None: + """Return cached minutely forecast if available.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute("SELECT data FROM weather_minutely_cache WHERE user_id=?", (user_id,)).fetchone() + db.close() + if row and row["data"]: + return json.loads(row["data"]) + return None + + +# ─── Weather Warnings (manual, rate-limited) ─ + +def fetch_warnings(user_id: int = None) -> dict: + """Fetch weather warnings from QWeather v7/warning/now. + + Rate-limited: once per 3 minutes. Returns cooldown info on rejection. + """ + if user_id is None: + user_id = _uid() + # Check cooldown + db = get_db(current_app.config["DB_PATH"]) + row = db.execute( + "SELECT fetched_at FROM weather_warning_cache WHERE user_id=?", (user_id,) + ).fetchone() + if row and row["fetched_at"]: + try: + last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S") + elapsed = (tz_now() - last).total_seconds() + if elapsed < _MINUTELY_COOLDOWN: + remaining = int(_MINUTELY_COOLDOWN - elapsed) + db.close() + return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining} + except Exception: + pass + db.close() + + cfg = get_config(user_id) + headers = _get_auth_header(cfg) + if not headers: + return {"error": "未配置认证"} + + lat = round(cfg.get("lat") or 39.9042, 2) + lon = round(cfg.get("lon") or 116.4074, 2) + host = _api_host(cfg) + url = f"{host}/weatheralert/v1/current/{lat}/{lon}" + params = {"lang": "zh"} + + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.status_code == 403: + return {"error": "天气预警为付费功能,当前订阅不可用", "unavailable": True} + if resp.status_code != 200: + return {"error": f"API 返回 {resp.status_code}"} + data = resp.json() + alert_list = data.get("alerts", []) + except Exception as e: + return {"error": str(e)} + + now_str = tz_strftime() + result = { + "warnings": alert_list, + "fetched_at": now_str, + "zero_result": data.get("metadata", {}).get("zeroResult", False), + } + db = get_db(current_app.config["DB_PATH"]) + db.execute("DELETE FROM weather_warning_cache WHERE user_id=?", (user_id,)) + db.execute( + "INSERT INTO weather_warning_cache (user_id, data, fetched_at) VALUES (?, ?, ?)", + (user_id, json.dumps(result), now_str) + ) + db.commit() + db.close() + return result + + +def get_cached_warnings(user_id: int = None) -> dict | None: + """Return cached warnings if available.""" + if user_id is None: + user_id = _uid() + db = get_db(current_app.config["DB_PATH"]) + row = db.execute("SELECT data FROM weather_warning_cache WHERE user_id=?", (user_id,)).fetchone() + db.close() + if row and row["data"]: + return json.loads(row["data"]) + return None diff --git a/backend/webauthn_mgr.py b/backend/webauthn_mgr.py new file mode 100644 index 0000000..97e9ec0 --- /dev/null +++ b/backend/webauthn_mgr.py @@ -0,0 +1,255 @@ +"""WebAuthn (Passkey) manager — registration and authentication ceremonies. + +Multi-user support: +- Registration is bound to a concrete user id (challenge keyed by user id, + user handle = "user_"). +- Login ceremonies are credential-driven: after `complete_authentication`, + the caller maps the returned credential id to its owner via + `models.find_user_by_credential_id`. +- `begin_authentication` can be restricted to a single credential for + re-authentication ceremonies. + +Uses the `webauthn` library (v2.x/v3.x). +Requires HTTPS (or localhost) to function. +""" +from dataclasses import asdict +from webauthn.helpers import bytes_to_base64url +from webauthn import ( + generate_registration_options, + verify_registration_response, + generate_authentication_options, + verify_authentication_response, +) +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + ResidentKeyRequirement, + UserVerificationRequirement, + PublicKeyCredentialDescriptor, +) +from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse + + +# snake_case → camelCase mapping for browser WebAuthn API +_SNAKE_TO_CAMEL = { + "pub_key_cred_params": "pubKeyCredParams", + "exclude_credentials": "excludeCredentials", + "authenticator_selection": "authenticatorSelection", + "allow_credentials": "allowCredentials", + "rp_id": "rpId", + "user_verification": "userVerification", + "resident_key": "residentKey", + "authenticator_attachment": "authenticatorAttachment", + "require_resident_key": "requireResidentKey", + "user_handle": "userHandle", + "credential_id": "credentialId", + "credential_public_key": "credentialPublicKey", + "sign_count": "signCount", + "cred_props": "credProps", + "public_key": "publicKey", + "private_key": "privateKey", + "display_name": "displayName", +} + + +def _snake_to_camel(name: str) -> str: + """Convert snake_case key to camelCase, with known overrides.""" + if name in _SNAKE_TO_CAMEL: + return _SNAKE_TO_CAMEL[name] + parts = name.split("_") + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + +# Relying Party info +RP_NAME = "Cal Display" +RP_ID = None # set dynamically from request.host +ORIGIN = None # set dynamically + +# In-memory challenge stores +_CHALLENGES: dict = {} # "login" → challenge +_REGISTRATION_CHALLENGES: dict = {} # "reg_" → challenge + + +def _get_rp_id(request) -> str: + """Extract RP ID from the request host (e.g. 'example.com' or '192.168.1.100').""" + host = request.host.split(":")[0] # strip port + return host + + +def _get_origin(request) -> str: + """Extract origin from the request (scheme + host).""" + scheme = request.scheme + return f"{scheme}://{request.host}" + + +def _serialize_options(options) -> dict: + """Serialize a webauthn options dataclass to a browser-ready dict. + Converts bytes to base64url strings AND snake_case keys to camelCase. + """ + d = asdict(options) + return _bytes_to_json(d) + + +def _bytes_to_json(obj): + """Recursively convert bytes→base64url and snake_case→camelCase.""" + if isinstance(obj, bytes): + return bytes_to_base64url(obj) + elif isinstance(obj, dict): + return {_snake_to_camel(k): _bytes_to_json(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_bytes_to_json(v) for v in obj] + elif isinstance(obj, tuple): + return tuple(_bytes_to_json(v) for v in obj) + return obj + + +# ─── Registration ──────────────────────────── + +def begin_registration(request, user_id: int, display_name: str = "", + existing_credential_id: str = None) -> dict: + """Start WebAuthn registration for a specific user. Returns options dict.""" + rp_id = _get_rp_id(request) + origin = _get_origin(request) + + user_name = f"user_{user_id}" + user_handle = user_name.encode("utf-8") + + # If the user already has a passkey, exclude it so they can't re-register same credential + exclude_credentials = [] + if existing_credential_id: + exclude_credentials.append( + PublicKeyCredentialDescriptor(id=bytes.fromhex(existing_credential_id)) + ) + + options = generate_registration_options( + rp_id=rp_id, + rp_name=RP_NAME, + user_id=user_handle, + user_name=user_name, + user_display_name=display_name or user_name, + attestation="none", + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + exclude_credentials=exclude_credentials, + ) + + # Store challenge for verification + _REGISTRATION_CHALLENGES[f"reg_{user_id}"] = options.challenge + + return _serialize_options(options) + + +def complete_registration(request, credential: dict) -> dict: + """Complete WebAuthn registration. + + Returns {'ok': True, 'user_id': int, 'credential_id': str, 'public_key': str} + or raises an exception on failure. + """ + rp_id = _get_rp_id(request) + origin = _get_origin(request) + + user_name = credential.get("user_name", "") + # Derive the target user id from the registration challenge key. + challenge_key = f"reg_{user_name}" + expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) + if not expected_challenge: + # Self-registration fallback: the browser may not echo the user handle, + # so resolve the target from the session user. + try: + from flask import session + uid = session.get("user_id") + if uid is not None: + challenge_key = f"reg_{uid}" + expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) + user_name = f"user_{uid}" + except Exception: + pass + if not expected_challenge: + raise ValueError("No registration challenge found. Please restart registration.") + user_id = int(user_name.split("_")[-1]) + + try: + # Library expects rawId field (browser-native camelCase, base64url string) + if "rawId" not in credential: + credential["rawId"] = credential.get("id", "") + + verification = verify_registration_response( + credential=credential, + expected_challenge=expected_challenge, + expected_rp_id=rp_id, + expected_origin=origin, + ) + except (InvalidRegistrationResponse, Exception) as e: + raise ValueError(f"Registration verification failed: {e}") + + return { + "ok": True, + "user_id": user_id, + "credential_id": verification.credential_id.hex(), + "public_key": verification.credential_public_key.hex(), + } + + +# ─── Authentication (Login / Re-auth) ──────── + +def begin_authentication(request, allow_credential_ids: list = None) -> dict: + """Start WebAuthn authentication. Returns options dict for the frontend. + + allow_credential_ids: optional list of hex credential ids to restrict the + ceremony to (used for re-authentication of the current user). + """ + rp_id = _get_rp_id(request) + origin = _get_origin(request) + + allow_credentials = [] + if allow_credential_ids: + allow_credentials = [ + PublicKeyCredentialDescriptor(id=bytes.fromhex(cid)) + for cid in allow_credential_ids + ] + + options = generate_authentication_options( + rp_id=rp_id, + user_verification=UserVerificationRequirement.PREFERRED, + allow_credentials=allow_credentials, + ) + + # Store challenge for verification + _CHALLENGES["login"] = options.challenge + + return _serialize_options(options) + + +def complete_authentication(request, credential: dict, stored_credential_id: str, stored_public_key: str) -> dict: + """Complete WebAuthn authentication. + Returns {'ok': True, 'credential_id': str} or raises on failure. + """ + rp_id = _get_rp_id(request) + origin = _get_origin(request) + + expected_challenge = _CHALLENGES.pop("login", None) + if not expected_challenge: + raise ValueError("No authentication challenge found. Please start again.") + + try: + # Library expects rawId field (browser-native camelCase, base64url string) + if "rawId" not in credential: + credential["rawId"] = credential.get("id", "") + + verification = verify_authentication_response( + credential=credential, + expected_challenge=expected_challenge, + expected_rp_id=rp_id, + expected_origin=origin, + credential_public_key=bytes.fromhex(stored_public_key), + credential_current_sign_count=0, # we don't track sign count + require_user_verification=False, + ) + except (InvalidAuthenticationResponse, Exception) as e: + raise ValueError(f"Authentication verification failed: {e}") + + return { + "ok": True, + "credential_id": verification.credential_id.hex(), + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a15b34 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + app: + build: . + container_name: cal-display + restart: unless-stopped + ports: + - "5000:5000" + volumes: + # 持久化数据库 + - ./data:/app/data + # 持久化项目代码(首次自动初始化为镜像内的克隆内容,之后 git pull 生效) + - app_code:/app + environment: + - FLASK_BIND=0.0.0.0 + - FLASK_DB_PATH=/app/data/data.db + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-} + - FLASK_HTTPS=${FLASK_HTTPS:-} + +volumes: + app_code: diff --git a/frontend/static/css/control.css b/frontend/static/css/control.css new file mode 100644 index 0000000..97dd61d --- /dev/null +++ b/frontend/static/css/control.css @@ -0,0 +1,350 @@ +/* Control page styles - desktop layout */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0f1117; + --card-bg: #1a1d2e; + --text: #e0e0e0; + --text-dim: #888; + --accent: #4A90D9; + --border: rgba(255,255,255,0.10); + --radius: 8px; + --btn-secondary: rgba(255,255,255,0.08); + --warn-bg: rgba(255,200,87,0.12); + --badge-on-bg: rgba(76,175,80,0.18); + --badge-on-fg: #7bc47f; + --badge-off-bg: rgba(224,85,85,0.18); + --badge-off-fg: #e28a8a; +} + +/* 浅色主题:白偏蓝背景、黑色字体与图标 */ +.theme-light { + --bg: #e8edf4; + --card-bg: #ffffff; + --text: #1c2333; + --text-dim: #5b6a7e; + --accent: #2f6fb2; + --border: #d7dfe8; + --radius: 8px; + --btn-secondary: #e4e9f0; + --warn-bg: #fffbea; + --badge-on-bg: #e8f5e9; + --badge-on-fg: #2e7d32; + --badge-off-bg: #ffebee; + --badge-off-fg: #c62828; +} + +/* 主题切换动画:切换瞬间由 theme.js 临时加 .theme-anim */ +html.theme-anim, +html.theme-anim * { + transition: background-color 0.55s ease, color 0.55s ease, + border-color 0.55s ease, box-shadow 0.55s ease !important; +} + +html, body { + font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +#app { + max-width: 960px; + margin: 0 auto; + padding: 20px; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} + +header h1 { + font-size: 22px; + font-weight: 600; +} + +#tabs { + display: flex; + gap: 4px; +} + +.tab { + padding: 8px 20px; + border: none; + background: var(--card-bg); + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; + color: var(--text-dim); + border: 1px solid var(--border); +} + +.tab.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.section { + background: var(--card-bg); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + border: 1px solid var(--border); +} + +.section h3 { + font-size: 16px; + margin-bottom: 14px; + color: var(--text); + font-weight: 500; +} + +/* Forms */ +.form-row { + margin-bottom: 12px; +} + +.form-row label { + display: block; + font-size: 13px; + color: var(--text-dim); + margin-bottom: 4px; +} + +.inline-form { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +input, select, button { + font-family: inherit; + font-size: 14px; +} + +input[type="text"], +input[type="url"], +input[type="number"], +input[type="time"], +select { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + color: var(--text); + min-width: 120px; +} + +input[type="color"] { + width: 36px; + height: 36px; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + padding: 2px; +} + +button { + padding: 8px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; +} + +button:hover { + opacity: 0.9; +} + +button.btn-secondary { + background: var(--btn-secondary); + color: var(--text); +} + +button.btn-danger { + background: #E05555; +} + +/* Lists */ +.group-item, .alarm-item, .clock-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 8px; +} + +.group-item .info, .alarm-item .info, .clock-item .info { + display: flex; + align-items: center; + gap: 10px; +} + +.group-color-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.group-name { + font-weight: 500; +} + +.group-sources { + font-size: 12px; + color: var(--text-dim); +} + +.group-actions { + display: flex; + gap: 6px; +} + +.group-actions button { + font-size: 12px; + padding: 4px 10px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.badge-on { background: var(--badge-on-bg); color: var(--badge-on-fg); } +.badge-off { background: var(--badge-off-bg); color: var(--badge-off-fg); } + +.small-btn { + font-size: 12px; + padding: 4px 10px; + background: var(--btn-secondary); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} + +/* Day picker */ +.day-picker { + display: flex; + gap: 4px; +} + +.day-picker label { + display: flex; + align-items: center; + gap: 2px; + font-size: 13px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border); +} + +.day-picker label:has(input:checked) { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.day-picker input[type="checkbox"] { + display: none; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 20px; + right: 20px; + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + animation: fadeIn 0.3s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Responsive breakpoints ────────────────── */ + +/* Tablet: 641–1024px */ +@media (max-width: 1024px) { + #app { max-width: 100%; padding: 16px; } + .section { padding: 16px; } +} + +/* Phone: ≤ 640px */ +@media (max-width: 640px) { + #app { padding: 10px; } + header { flex-direction: column; align-items: flex-start; } + header h1 { font-size: 18px; } + #tabs { width: 100%; } + .tab { flex: 1; text-align: center; padding: 10px 8px; font-size: 13px; } + + .section { padding: 14px; margin-bottom: 12px; } + .section h3 { font-size: 15px; margin-bottom: 10px; } + + .inline-form { flex-direction: column; align-items: stretch; gap: 8px; } + input, select, button { width: 100%; min-width: 0; font-size: 15px; } + input[type="text"], + input[type="url"], + input[type="number"], + input[type="time"], + select { padding: 10px 12px; } + button { padding: 10px 16px; } + + /* Day picker: wrap to two rows */ + .day-picker { flex-wrap: wrap; gap: 4px; } + .day-picker label { padding: 6px 10px; font-size: 14px; } + + /* List items: stack vertically */ + .group-item, .alarm-item, .clock-item { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .group-actions { width: 100%; justify-content: flex-end; } + + /* Toast: full-width at bottom */ + .toast { + left: 10px; right: 10px; bottom: 10px; + text-align: center; font-size: 14px; + } +} + +/* Large desktop: > 1400px */ +@media (min-width: 1400px) { + #app { max-width: 1200px; } + .section { padding: 28px; } + input[type="text"], + input[type="url"], + input[type="number"], + input[type="time"], + select { padding: 10px 14px; font-size: 15px; } + button { padding: 10px 20px; font-size: 15px; } +} diff --git a/frontend/static/css/display.css b/frontend/static/css/display.css new file mode 100644 index 0000000..9742bf0 --- /dev/null +++ b/frontend/static/css/display.css @@ -0,0 +1,867 @@ +/* Display page styles — responsive, baseline 800×480 */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0f1117; + --card-bg: #1a1d2e; + --card-active: #252840; + --text: #e0e0e0; + --text-dim: #888; + --accent: #4A90D9; + --accent-warm: #E8943A; + --accent-green: #4CAF50; + --accent-red: #E05555; + --radius: clamp(6px, 1.25vw, 16px); + /* 半透明白系列(背景/边框/分隔线,随主题切换) */ + --w03: rgba(255,255,255,0.03); + --w04: rgba(255,255,255,0.04); + --w05: rgba(255,255,255,0.05); + --w06: rgba(255,255,255,0.06); + --w08: rgba(255,255,255,0.08); + --w10: rgba(255,255,255,0.10); + --w14: rgba(255,255,255,0.14); + --w15: rgba(255,255,255,0.15); + --w16: rgba(255,255,255,0.16); + --card-border: rgba(255,255,255,0.12); +} + +/* 浅色主题:白偏蓝背景、黑色字体与图标(html.theme-light 时生效) */ +.theme-light { + --bg: #e8edf4; + --card-bg: #ffffff; + --card-active: #dde4ee; + --text: #1c2333; + --text-dim: #5b6a7e; + --accent: #2f6fb2; + --accent-warm: #cf7416; + --accent-green: #3a9450; + --accent-red: #c74545; + --radius: clamp(6px, 1.25vw, 16px); + --w03: rgba(28,35,51,0.03); + --w04: rgba(28,35,51,0.04); + --w05: rgba(28,35,51,0.05); + --w06: rgba(28,35,51,0.06); + --w08: rgba(28,35,51,0.08); + --w10: rgba(28,35,51,0.10); + --w14: rgba(28,35,51,0.14); + --w15: rgba(28,35,51,0.15); + --w16: rgba(28,35,51,0.16); + --card-border: #c9d4e2; +} + +/* 浅色主题下的天气图标:还原彩色(深色下由 filter 转白) */ +.theme-light .card-weather-icon, +.theme-light .weather-icon, +.theme-light .hc-icon img { filter: none; } +/* 浅色主题下的 Sidebar 线性图标:黑色线条 */ +.theme-light .card-icon svg { color: #1c2333; } + +/* 主题切换动画:切换瞬间由 theme.js 临时加 .theme-anim,触发全局平滑过渡 */ +html.theme-anim, +html.theme-anim * { + transition: background-color 0.55s ease, color 0.55s ease, + border-color 0.55s ease, box-shadow 0.55s ease, + fill 0.55s ease, stroke 0.55s ease !important; +} + +html, body { + width: 100vw; + height: 100vh; + height: 100dvh; /* dynamic viewport for mobile browsers */ + overflow: hidden; + font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: var(--bg); + color: var(--text); + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; +} + +#app { + position: relative; + display: flex; + width: 100%; + height: 100%; + padding: clamp(4px, 1vw, 16px); /* 左右对称:大卡片左边缘与右边缘距离一致 */ + gap: clamp(4px, 1vw, 16px); + /* 布局变量:Sidebar 浮层与大卡片左缘间距、Sidebar 宽度 */ + --sidebar-gap: clamp(14px, 1.8vw, 28px); + --sidebar-w: clamp(140px, 25vw, 280px); +} + +/* ── Sidebar(浮层:叠在大卡片之上,上/下/左与大卡片边缘等距)── */ +#sidebar { + position: absolute; + left: var(--sidebar-gap); + top: var(--sidebar-gap); + bottom: var(--sidebar-gap); + width: var(--sidebar-w); + min-width: 0; + z-index: 2; + display: flex; + flex-direction: column; + gap: clamp(4px, 0.75vw, 12px); +} + +.card { + flex: 1; + background: var(--card-bg); + border-radius: var(--radius); + padding: clamp(6px, 1.25vw, 18px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid var(--card-border); /* Sidebar 小卡片框框(与大卡片区分层次) */ + transition: border-color 0.2s, background 0.2s; + position: relative; + overflow: hidden; +} + +.card:active { background: var(--card-active); } +.card.active { border-color: var(--accent); } + +.card-icon { font-size: clamp(16px, 2.75vw, 36px); margin-bottom: clamp(2px, 0.25vw, 6px); } +/* Sidebar 线性图标:白色线条、无填充,尺寸与天气图标一致 */ +.card-icon svg { + width: clamp(24px, 3.5vw, 44px); + height: clamp(24px, 3.5vw, 44px); + color: #fff; /* stroke 使用 currentColor */ +} +/* 图标+标签 左列容器(默认纵向排列,视觉与原先一致) */ +.card-left { + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(2px, 0.25vw, 6px); + min-width: 0; +} +.card-left .card-icon { margin-bottom: 0; } +.card-left .card-label { margin-bottom: 0; } +.card-weather-icon { width: clamp(24px, 3.5vw, 44px); height: clamp(24px, 3.5vw, 44px); filter: brightness(0) invert(1); } +.card-label { font-size: clamp(10px, 1.4vw, 16px); color: var(--text-dim); margin-bottom: clamp(2px, 0.25vw, 6px); } +.card-value { + font-size: clamp(11px, 1.6vw, 20px); font-weight: 600; text-align: center; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* ── Panel ────────────────────────────────── */ +#panel { + flex: 1; + min-width: 0; + background: var(--card-bg); + border-radius: var(--radius); + /* 左侧预留 Sidebar 区域:内容左缘 = sidebar左距 + sidebar宽 + 等距, + 比 Sidebar 离大卡片左边缘的距离更大 */ + padding: clamp(10px, 2vw, 28px) clamp(12px, 2.5vw, 36px) + clamp(10px, 2vw, 28px) + calc(var(--sidebar-gap) + var(--sidebar-w) + var(--sidebar-gap)); + overflow-y: auto; + position: relative; +} + +.lock-btn { + position: absolute; + top: clamp(6px, 1vh, 14px); + right: clamp(8px, 1.2vw, 18px); + background: var(--w06); + border: 1px solid var(--w10); + border-radius: 8px; + color: var(--text-dim); + font-size: clamp(14px, 2vw, 22px); + padding: clamp(4px, 0.6vh, 8px) clamp(8px, 1vw, 14px); + cursor: pointer; + line-height: 1; + z-index: 10; + transition: background 0.15s, color 0.15s; +} +.lock-btn:active { + background: var(--w14); + color: var(--text); +} + +.panel-content { display: none; } +.panel-content.active { display: block; } +#panel-pomodoro.active { display: flex; flex-direction: column; height: 100%; } +.pomo-bg-wrap { + position: relative; + width: 100%; + flex: 1; + min-height: clamp(200px, 40vh, 400px); + border-radius: clamp(8px, 1.2vw, 16px); + overflow: hidden; +} + +.panel-content h2 { + font-size: clamp(14px, 2vw, 26px); color: var(--text-dim); margin-bottom: clamp(8px, 1.5vh, 16px); + font-weight: 400; border-bottom: 1px solid var(--w08); + padding-bottom: clamp(6px, 1vh, 12px); +} + +/* ── Time ─────────────────────────────────── */ +.big-clock { font-size: clamp(32px, 7vw, 96px); font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 2px; margin-bottom: clamp(2px, 0.5vh, 8px); } +.date-str { font-size: clamp(12px, 2vw, 24px); color: var(--text-dim); margin-bottom: clamp(10px, 2vh, 24px); } +.clock-list { display: flex; flex-wrap: wrap; gap: clamp(6px, 1.25vw, 16px); } +.clock-item { background: var(--w05); border-radius: 8px; padding: clamp(6px, 1vw, 14px) clamp(10px, 1.75vw, 22px); } +.clock-item .city { font-size: clamp(11px, 1.5vw, 18px); color: var(--text-dim); } +.clock-item .time { font-size: clamp(16px, 2.5vw, 30px); font-weight: 600; } + +/* ── Weather ──────────────────────────────── */ +.weather-main { display: flex; align-items: center; gap: clamp(10px, 1.75vw, 24px); margin-bottom: clamp(8px, 1.5vh, 18px); } +.weather-temp-big { font-size: clamp(28px, 6.5vw, 88px); font-weight: 700; } +.weather-desc { font-size: clamp(14px, 2.25vw, 28px); color: var(--text-dim); } +.weather-icon { width: clamp(48px, 7vw, 88px); height: clamp(48px, 7vw, 88px); flex-shrink: 0; filter: brightness(0) invert(1); } +.weather-details { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(6px, 1vw, 14px); } +.weather-detail { background: var(--w04); border-radius: 6px; padding: clamp(6px, 1vw, 14px) clamp(10px, 1.5vw, 20px); font-size: clamp(11px, 1.75vw, 20px); } +.weather-detail .label { font-size: clamp(10px, 1.4vw, 16px); color: var(--text-dim); } + +/* ── Hourly Forecast ──────────────────────── */ +.hourly-section { margin-top: clamp(12px, 2vh, 24px); } +.hourly-title { font-size: clamp(12px, 1.6vw, 18px); color: var(--text-dim); margin-bottom: clamp(8px, 1vh, 14px); } +.hourly-list { + display: flex; gap: clamp(4px, 0.6vw, 10px); + overflow-x: auto; padding-bottom: clamp(4px, 0.5vh, 8px); + scrollbar-width: thin; +} +.hc-item { + flex: 0 0 auto; + min-width: clamp(44px, 6vw, 64px); + display: flex; flex-direction: column; align-items: center; + gap: clamp(2px, 0.3vh, 6px); + padding: clamp(6px, 0.8vw, 12px) clamp(4px, 0.6vw, 10px); + background: var(--w04); + border-radius: clamp(6px, 0.8vw, 12px); + border: 1px solid transparent; +} +.hc-item.hc-now { + background: rgba(74, 144, 217, 0.15); + border-color: var(--accent); +} +.hc-time { font-size: clamp(10px, 1.3vw, 15px); color: var(--text-dim); } +.hc-time .tomorrow { font-size: clamp(8px, 1vw, 12px); color: var(--accent-warm); margin-right: 2px; } +.hc-icon { font-size: clamp(16px, 2.2vw, 28px); line-height: 1; } +.hc-icon img { display: block; width: 100%; height: 100%; filter: brightness(0) invert(1); } +.hc-temp { font-size: clamp(14px, 1.8vw, 22px); font-weight: 600; } +.hc-pop { font-size: clamp(10px, 1.2vw, 14px); color: var(--accent-warm); } + +/* ── Weather Update Info ──────────────────── */ +.update-info { + margin-top: clamp(10px, 1.5vh, 18px); + padding: clamp(6px, 0.8vw, 12px) clamp(8px, 1vw, 14px); + background: var(--w03); + border-radius: clamp(4px, 0.6vw, 8px); + font-size: clamp(10px, 1.3vw, 14px); + color: var(--text-dim); + line-height: 1.8; +} +.update-info .label { + color: var(--text-dim); + opacity: 0.7; +} + +/* ── Minutely + Warnings ──────────────────── */ +.section-title-row { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: clamp(6px, 0.8vh, 10px); +} +.mini-refresh-btn { + padding: clamp(2px, 0.3vw, 6px) clamp(8px, 1vw, 14px); + font-size: clamp(10px, 1.2vw, 14px); + border: 1px solid var(--w15); + border-radius: 6px; + background: var(--w08); + color: var(--text-dim); + cursor: pointer; + font-family: inherit; +} +.mini-refresh-btn:active { background: var(--w16); } + +/* Minutely bars */ +.minutely-bars { + display: flex; gap: 1px; align-items: flex-end; + height: clamp(60px, 8vh, 100px); + overflow-x: auto; padding-bottom: 4px; +} +.mb-col { + flex: 0 0 clamp(16px, 2.2vw, 28px); + display: flex; flex-direction: column; align-items: center; + justify-content: flex-end; height: 100%; +} +.mb-bar { + width: 100%; + background: var(--accent); + border-radius: 2px 2px 0 0; + min-height: 2px; + opacity: 0.7; + transition: height 0.3s; +} +.mb-time { + font-size: clamp(8px, 0.9vw, 10px); + color: var(--text-dim); + margin-top: 2px; +} + +/* Warning cards */ +.warning-card { + background: var(--w04); + border-radius: clamp(4px, 0.6vw, 8px); + padding: clamp(8px, 1vw, 14px) clamp(10px, 1.5vw, 18px); + margin-bottom: clamp(4px, 0.6vh, 8px); +} +.warning-title { + font-size: clamp(12px, 1.6vw, 18px); + font-weight: 600; + margin-bottom: clamp(4px, 0.4vh, 6px); +} +.warning-text { + font-size: clamp(11px, 1.4vw, 16px); + color: var(--text-dim); + line-height: 1.5; +} + +/* ── Display Toast ────────────────────────── */ +.display-toast { + position: fixed; + bottom: clamp(20px, 4vh, 40px); + left: 50%; + transform: translateX(-50%); + background: rgba(30, 30, 40, 0.95); + color: #fff; + padding: clamp(10px, 1.5vw, 18px) clamp(20px, 3vw, 36px); + border-radius: clamp(8px, 1vw, 14px); + font-size: clamp(13px, 1.8vw, 22px); + z-index: 300; + border: 1px solid var(--w10); + animation: toast-in 0.25s ease; + pointer-events: none; +} +@keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +/* ── Calendar ─────────────────────────────── */ +.event-list { max-height: calc(100vh - clamp(100px, 29vw, 220px)); overflow-y: auto; } +.event-item { display: flex; align-items: center; gap: clamp(8px, 1.5vw, 20px); padding: clamp(6px, 1vh, 12px) 0; border-bottom: 1px solid var(--w05); } +.event-dot { width: clamp(6px, 1vw, 12px); height: clamp(6px, 1vw, 12px); border-radius: 50%; flex-shrink: 0; } +.event-time { font-size: clamp(16px, 2.5vw, 30px); font-weight: 600; font-variant-numeric: tabular-nums; min-width: clamp(40px, 6.5vw, 70px); } +.event-summary { font-size: clamp(13px, 1.9vw, 24px); } +.event-sub { font-size: clamp(11px, 1.5vw, 18px); color: var(--text-dim); } + +/* ── Pomodoro ─────────────────────────────── */ +.pomo-bg-img { + position: absolute; + inset: 0; + background-image: url('/static/img/bg_pomodoro.jpg'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} +/* If bg_pomodoro.jpg doesn't exist, the 404 is silently ignored — no extra CSS needed */ +.pomo-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: clamp(24px, 4vh, 48px) clamp(16px, 3vh, 32px); +} +.pomo-glass { + display: inline-block; + text-align: center; + padding: clamp(8px, 1.2vh, 16px) clamp(16px, 2.5vw, 36px); + background: rgba(0,0,0,0.30); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: clamp(10px, 1.5vw, 20px); +} +#pomodoro-phase { font-size: clamp(16px, 2.5vw, 28px); color: var(--accent-green); margin-bottom: clamp(4px, 0.6vh, 10px); font-weight: 600; } +#pomodoro-timer { font-size: clamp(36px, 8vw, 100px); font-weight: 700; font-variant-numeric: tabular-nums; margin-bottom: clamp(4px, 0.6vh, 10px); } +#pomodoro-cycles { font-size: clamp(12px, 2vw, 20px); color: var(--text-dim); } +#pomodoro-next-break { font-size: clamp(13px, 2vw, 22px); color: var(--accent-warm); margin-top: clamp(6px, 0.8vh, 12px); font-weight: 500; } +/* Pomodoro sidebar card layout */ +.pomo-sidebar-row { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.pomo-sidebar-btns { + position: absolute; + right: clamp(4px, 0.5vw, 8px); + top: clamp(4px, 0.5vw, 8px); + bottom: clamp(4px, 0.5vw, 8px); + display: flex; + flex-direction: column; + gap: clamp(2px, 0.3vw, 5px); +} +.pomo-sidebar-info { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 0; +} +.pomo-mini-btn { + flex: 1; + min-width: 0; + padding: 0 clamp(6px, 0.8vw, 12px); + font-size: clamp(9px, 1.2vw, 14px); + font-weight: 600; + border: none; + border-radius: clamp(3px, 0.4vw, 6px); + cursor: pointer; + font-family: inherit; + color: #fff; + line-height: 1; +} +.pomo-mini-btn:active { opacity: 0.7; } +.pomo-mini-primary { background: var(--accent-green); } +.pomo-mini-secondary { background: var(--accent-warm); } +.pomo-mini-danger { background: var(--accent-red); } + +/* ── Alarm Overlay ────────────────────────── */ +.alarm-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + width: 100vw; height: 100vh; + height: 100dvh; + background: rgba(15, 17, 23, 0.92); + display: flex; align-items: center; justify-content: center; + z-index: 100; +} +.alarm-overlay.hidden { display: none; } +.alarm-overlay-box { + text-align: center; + padding: clamp(24px, 5vw, 56px) clamp(32px, 6.5vw, 72px); + background: var(--card-bg); border-radius: clamp(12px, 2vw, 24px); + border: 2px solid var(--accent-red); + animation: overlay-in 0.3s ease; +} +@keyframes overlay-in { + from { transform: scale(0.85); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} +.alarm-overlay-icon { font-size: clamp(40px, 7vw, 80px); margin-bottom: clamp(8px, 1.5vh, 16px); } +.alarm-overlay-title { font-size: clamp(18px, 2.75vw, 36px); font-weight: 700; margin-bottom: clamp(6px, 1vh, 12px); color: var(--accent-red); } +.alarm-overlay-info { font-size: clamp(14px, 2vw, 26px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 32px); line-height: 1.6; } +.alarm-dismiss-btn { + padding: clamp(10px, 1.75vw, 20px) clamp(32px, 6vw, 64px); + font-size: clamp(14px, 2.25vw, 26px); font-weight: 600; + background: var(--accent-red); color: #fff; + border: none; border-radius: clamp(8px, 1.25vw, 14px); cursor: pointer; + font-family: inherit; +} +.alarm-dismiss-btn:active { opacity: 0.8; } + +/* ── Animations ───────────────────────────── */ +.card.alert { + border-color: var(--accent-red); + animation: alert-flash 0.5s ease 6; +} + +@keyframes alert-flash { + 0%, 100% { background: var(--card-bg); } + 50% { background: rgba(224, 85, 85, 0.3); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Scrollbar ────────────────────────────── */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--0.1); border-radius: 2px; } + +/* ── Small-screen fine-tuning (< 600px) ────── + Preserves the existing 800×480 design intent while keeping + things usable on even narrower viewports (phones). */ +@media (max-width: 600px) { + #app { + flex-direction: column; + padding: clamp(3px, 1.5vw, 8px); + gap: clamp(3px, 1.5vw, 8px); + } + #sidebar { + position: static; /* 小屏恢复流式布局(顶部横条) */ + flex-direction: row; + width: 100%; + height: clamp(60px, 15vh, 100px); + gap: clamp(3px, 1.5vw, 8px); + } + #panel { flex: 1; padding: clamp(10px, 2vw, 28px) clamp(12px, 2.5vw, 36px); } + .card { min-width: 0; } + .card-icon { font-size: clamp(14px, 4vw, 24px); } + .card-label { font-size: clamp(9px, 2.5vw, 14px); } + .card-value { font-size: clamp(10px, 2.8vw, 16px); } +} + +/* ── 超宽屏(宽高比严格大于 16:9):Sidebar 卡片横向布局 ── + 图标+标签在左(card-left 列),具体信息在右侧自动换行, + 避免窄高卡片内文字放大覆盖。番茄钟按钮保持右侧竖排, + info 预留按钮区域避免覆盖。 */ +@media (min-aspect-ratio: 1601/900) { + #sidebar .card:not(.card-pomodoro) { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: clamp(8px, 1vw, 16px); + padding: clamp(4px, 0.7vw, 10px) clamp(10px, 1.2vw, 18px); + } + /* 左右 1:1:图标+标签占左半(内容居中),信息占右半(文字居中) */ + #sidebar .card-left { flex: 1 1 0; min-width: 0; } + #sidebar .card:not(.card-pomodoro) .card-value, + #sidebar .pomo-sidebar-info .card-value { + flex: 1 1 0; + min-width: 0; + text-align: center; + white-space: normal; /* 覆盖全局 nowrap:允许换行,line-clamp 才生效 */ + line-height: 1.35; + word-break: break-word; + /* 长文本上限 2 行:超出在底部截断并以省略号代替 */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + /* 番茄钟:与其他卡片一致——整卡左右 1:1(图标+标签左半、信息右半)。 + 按钮与 value 互斥:value 显示时按钮隐藏,按钮显示时 value 隐藏, + 按钮 absolute 落在右侧空区,互不遮挡 */ + #sidebar .card-pomodoro { flex-direction: row; } + #sidebar .pomo-sidebar-info { + width: 100%; + height: 100%; + flex-direction: row; + align-items: center; + justify-content: center; + gap: clamp(8px, 1vw, 16px); + } +} + +/* ── Large-screen caps (> 1920px) ─────────── + Once the viewport is wide enough that all clamp() values + hit their maximums, constrain the app so it doesn't keep + stretching. */ +@media (min-width: 1920px) { + html, body { + display: flex; + align-items: center; + justify-content: center; + } + #app { + max-width: 1920px; + max-height: 1080px; + } +} + +/* ── Panel header buttons (refresh + lock) ── */ +.panel-header-btns { + position: absolute; + top: clamp(6px, 1vh, 14px); + right: clamp(8px, 1.2vw, 18px); + display: flex; + gap: clamp(4px, 0.6vw, 10px); + z-index: 10; +} +.panel-header-btns .lock-btn { + position: static; +} + +/* ── Panel Add Button ─────────────────────── */ +.panel-add-btn { + display: block; + width: 100%; + margin-top: clamp(8px, 1.5vh, 16px); + padding: clamp(8px, 1.25vw, 16px); + font-size: clamp(13px, 2vw, 22px); + font-weight: 600; + background: var(--w06); + border: 1px dashed var(--0.18); + border-radius: clamp(6px, 1vw, 12px); + color: var(--text-dim); + cursor: pointer; + font-family: inherit; + transition: background 0.15s, color 0.15s; +} +.panel-add-btn:active { + background: var(--0.12); + color: var(--text); +} + +/* ── Alarm item action buttons (edit/delete) ─ */ +.alarm-actions { + display: flex; + gap: clamp(4px, 0.6vw, 8px); + flex-shrink: 0; +} +.alarm-action-btn { + padding: clamp(3px, 0.5vw, 6px) clamp(8px, 1.2vw, 14px); + font-size: clamp(10px, 1.4vw, 16px); + border: 1px solid var(--w15); + border-radius: 6px; + background: var(--w06); + color: var(--text-dim); + cursor: pointer; + font-family: inherit; + transition: background 0.15s, color 0.15s; +} +.alarm-action-btn:active { + background: var(--w14); +} +.alarm-action-btn.edit-btn:hover { color: var(--accent); border-color: var(--accent); } +.alarm-action-btn.del-btn:hover { color: var(--accent-red); border-color: var(--accent-red); } + +/* ── Alarm item layout (with actions) ─────── */ +.alarm-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: clamp(8px, 1.25vw, 18px) clamp(10px, 1.75vw, 22px); + background: var(--w04); + border-radius: 8px; + margin-bottom: clamp(6px, 1vh, 12px); +} +.alarm-item .alarm-info { + display: flex; + flex-direction: column; + min-width: 0; +} +.alarm-item .alarm-time { + font-size: clamp(20px, 3.5vw, 42px); + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.alarm-item .alarm-label { + font-size: clamp(12px, 1.75vw, 22px); + color: var(--text-dim); +} +.alarm-item .alarm-days { + font-size: clamp(10px, 1.4vw, 16px); + color: var(--text-dim); +} +.alarm-item .alarm-status { + font-size: clamp(10px, 1.2vw, 14px); + color: var(--text-dim); + text-align: right; +} +.alarm-item.triggered { + background: rgba(224, 85, 85, 0.25); + border: 2px solid var(--accent-red); + animation: pulse 1s infinite; +} + +/* ── Modal Overlay ────────────────────────── */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + width: 100vw; height: 100vh; + height: 100dvh; + background: rgba(15, 17, 23, 0.90); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.modal-overlay.hidden { display: none; } +.modal-box { + background: var(--card-bg); + border-radius: clamp(12px, 2vw, 24px); + padding: clamp(20px, 3vw, 40px) clamp(24px, 4vw, 48px); + width: min(90vw, clamp(320px, 50vw, 500px)); + max-height: 90vh; + overflow-y: auto; + border: 1px solid var(--w10); + animation: overlay-in 0.25s ease; +} +.modal-title { + font-size: clamp(16px, 2.5vw, 28px); + font-weight: 700; + margin-bottom: clamp(16px, 2.5vh, 28px); + text-align: center; +} +.modal-field { + margin-bottom: clamp(14px, 2.5vh, 24px); +} +.modal-label { + font-size: clamp(12px, 1.6vw, 18px); + color: var(--text-dim); + margin-bottom: clamp(6px, 0.8vh, 10px); +} +.alarm-modal-name { + font-size: clamp(14px, 2vw, 22px); + color: var(--text); + padding: clamp(6px, 1vw, 12px); + background: var(--w04); + border-radius: 6px; +} +.modal-actions { + display: flex; + gap: clamp(10px, 1.5vw, 20px); + justify-content: center; + margin-top: clamp(16px, 3vh, 32px); +} + +/* Modal buttons */ +.pomo-btn { + padding: clamp(10px, 1.5vw, 18px) clamp(24px, 4vw, 48px); + font-size: clamp(14px, 2vw, 22px); + font-weight: 600; + border: none; + border-radius: clamp(8px, 1vw, 14px); + cursor: pointer; + font-family: inherit; + transition: opacity 0.15s; +} +.pomo-btn:active { opacity: 0.7; } +.pomo-btn-primary { background: var(--accent); color: #fff; } +.pomo-btn-secondary { background: var(--w08); color: var(--text-dim); border: 1px solid var(--0.12); } + +/* ── Time Picker (mouse-only) ─────────────── */ +.time-picker { + display: flex; + align-items: center; + justify-content: center; + gap: clamp(4px, 0.8vw, 12px); +} +.tp-col { + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(2px, 0.4vh, 6px); +} +.tp-btn { + width: clamp(40px, 6vw, 64px); + height: clamp(32px, 4vh, 48px); + border: 1px solid var(--w15); + border-radius: 8px; + background: var(--w06); + color: var(--text); + font-size: clamp(14px, 2vw, 24px); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: background 0.1s; + font-family: inherit; +} +.tp-btn:active { + background: var(--w16); +} +.tp-value { + font-size: clamp(32px, 6vw, 64px); + font-weight: 700; + font-variant-numeric: tabular-nums; + width: clamp(50px, 7vw, 80px); + text-align: center; + line-height: 1.3; +} +.tp-sep { + font-size: clamp(28px, 5vw, 56px); + font-weight: 700; + color: var(--text-dim); + margin-top: calc(clamp(32px, 4vh, 48px) + clamp(8px, 1.2vh, 18px)); +} + +/* ── Day Picker (modal style) ─────────────── */ +#alarm-modal-days { + display: flex; + gap: clamp(4px, 0.6vw, 8px); +} +.day-btn { + width: clamp(32px, 4.5vw, 48px); + height: clamp(32px, 4.5vw, 48px); + border-radius: 50%; + border: 1px solid var(--0.18); + background: var(--w06); + color: var(--text-dim); + font-size: clamp(12px, 1.6vw, 18px); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: inherit; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.day-btn:active { + background: var(--w14); +} +.day-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +/* ── Toggle Switch ────────────────────────── */ +.toggle-label { + display: inline-flex; + align-items: center; + gap: clamp(8px, 1vw, 14px); + cursor: pointer; + font-size: clamp(13px, 1.8vw, 20px); + color: var(--text); +} +.toggle-label input { display: none; } +.toggle-slider { + width: clamp(36px, 4vw, 48px); + height: clamp(20px, 2.5vh, 28px); + background: var(--w15); + border-radius: 14px; + position: relative; + transition: background 0.2s; +} +.toggle-slider::after { + content: ''; + position: absolute; + top: 2px; left: 2px; + width: calc(clamp(20px, 2.5vh, 28px) - 4px); + height: calc(clamp(20px, 2.5vh, 28px) - 4px); + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.toggle-label input:checked + .toggle-slider { + background: var(--accent-green); +} +.toggle-label input:checked + .toggle-slider::after { + transform: translateX(calc(clamp(36px, 4vw, 48px) - clamp(20px, 2.5vh, 28px))); +} + +/* ── Countdown List ───────────────────────── */ +.countdown-list .cd-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: clamp(8px, 1.25vw, 18px) clamp(10px, 1.75vw, 22px); + background: var(--w04); + border-radius: 8px; + margin-bottom: clamp(6px, 1vh, 12px); +} +.cd-item .cd-info { + display: flex; + flex-direction: column; + min-width: 0; +} +.cd-item .cd-time { + font-size: clamp(24px, 4vw, 52px); + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.cd-item .cd-label { + font-size: clamp(11px, 1.5vw, 18px); + color: var(--text-dim); +} +.cd-item .cd-remain { + font-size: clamp(14px, 2vw, 24px); + color: var(--accent-warm); + font-weight: 600; + text-align: right; + white-space: nowrap; +} +.cd-item .cd-remain.overdue { + color: var(--accent-red); +} diff --git a/frontend/static/icons/100-fill.svg b/frontend/static/icons/100-fill.svg new file mode 100644 index 0000000..8eb998a --- /dev/null +++ b/frontend/static/icons/100-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/100.svg b/frontend/static/icons/100.svg new file mode 100644 index 0000000..c09c805 --- /dev/null +++ b/frontend/static/icons/100.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1001.svg b/frontend/static/icons/1001.svg new file mode 100644 index 0000000..94a63dc --- /dev/null +++ b/frontend/static/icons/1001.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1002.svg b/frontend/static/icons/1002.svg new file mode 100644 index 0000000..40812fa --- /dev/null +++ b/frontend/static/icons/1002.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1003.svg b/frontend/static/icons/1003.svg new file mode 100644 index 0000000..62f2797 --- /dev/null +++ b/frontend/static/icons/1003.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1004.svg b/frontend/static/icons/1004.svg new file mode 100644 index 0000000..436bba6 --- /dev/null +++ b/frontend/static/icons/1004.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1005.svg b/frontend/static/icons/1005.svg new file mode 100644 index 0000000..e20a99b --- /dev/null +++ b/frontend/static/icons/1005.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1006.svg b/frontend/static/icons/1006.svg new file mode 100644 index 0000000..4c7c42c --- /dev/null +++ b/frontend/static/icons/1006.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1007.svg b/frontend/static/icons/1007.svg new file mode 100644 index 0000000..4e0cca2 --- /dev/null +++ b/frontend/static/icons/1007.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1008.svg b/frontend/static/icons/1008.svg new file mode 100644 index 0000000..329f6c7 --- /dev/null +++ b/frontend/static/icons/1008.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1009.svg b/frontend/static/icons/1009.svg new file mode 100644 index 0000000..e066c19 --- /dev/null +++ b/frontend/static/icons/1009.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/101-fill.svg b/frontend/static/icons/101-fill.svg new file mode 100644 index 0000000..dae24c3 --- /dev/null +++ b/frontend/static/icons/101-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/101.svg b/frontend/static/icons/101.svg new file mode 100644 index 0000000..690c923 --- /dev/null +++ b/frontend/static/icons/101.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1010.svg b/frontend/static/icons/1010.svg new file mode 100644 index 0000000..ce17e9b --- /dev/null +++ b/frontend/static/icons/1010.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1011.svg b/frontend/static/icons/1011.svg new file mode 100644 index 0000000..791c2a9 --- /dev/null +++ b/frontend/static/icons/1011.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1012.svg b/frontend/static/icons/1012.svg new file mode 100644 index 0000000..0ca6ee6 --- /dev/null +++ b/frontend/static/icons/1012.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1013.svg b/frontend/static/icons/1013.svg new file mode 100644 index 0000000..b1f0cdf --- /dev/null +++ b/frontend/static/icons/1013.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1014.svg b/frontend/static/icons/1014.svg new file mode 100644 index 0000000..4800932 --- /dev/null +++ b/frontend/static/icons/1014.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1015.svg b/frontend/static/icons/1015.svg new file mode 100644 index 0000000..9876330 --- /dev/null +++ b/frontend/static/icons/1015.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1016.svg b/frontend/static/icons/1016.svg new file mode 100644 index 0000000..903efef --- /dev/null +++ b/frontend/static/icons/1016.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1017.svg b/frontend/static/icons/1017.svg new file mode 100644 index 0000000..d8ef224 --- /dev/null +++ b/frontend/static/icons/1017.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1018.svg b/frontend/static/icons/1018.svg new file mode 100644 index 0000000..4a06063 --- /dev/null +++ b/frontend/static/icons/1018.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1019.svg b/frontend/static/icons/1019.svg new file mode 100644 index 0000000..7d81f72 --- /dev/null +++ b/frontend/static/icons/1019.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/102-fill.svg b/frontend/static/icons/102-fill.svg new file mode 100644 index 0000000..2add978 --- /dev/null +++ b/frontend/static/icons/102-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/102.svg b/frontend/static/icons/102.svg new file mode 100644 index 0000000..a37a503 --- /dev/null +++ b/frontend/static/icons/102.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1020.svg b/frontend/static/icons/1020.svg new file mode 100644 index 0000000..adcca8e --- /dev/null +++ b/frontend/static/icons/1020.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1021.svg b/frontend/static/icons/1021.svg new file mode 100644 index 0000000..24eb564 --- /dev/null +++ b/frontend/static/icons/1021.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1022.svg b/frontend/static/icons/1022.svg new file mode 100644 index 0000000..af2a3af --- /dev/null +++ b/frontend/static/icons/1022.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1023.svg b/frontend/static/icons/1023.svg new file mode 100644 index 0000000..8d07633 --- /dev/null +++ b/frontend/static/icons/1023.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1024.svg b/frontend/static/icons/1024.svg new file mode 100644 index 0000000..0dd41ac --- /dev/null +++ b/frontend/static/icons/1024.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1025.svg b/frontend/static/icons/1025.svg new file mode 100644 index 0000000..469aede --- /dev/null +++ b/frontend/static/icons/1025.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1026.svg b/frontend/static/icons/1026.svg new file mode 100644 index 0000000..5ebad73 --- /dev/null +++ b/frontend/static/icons/1026.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1027.svg b/frontend/static/icons/1027.svg new file mode 100644 index 0000000..434aa1e --- /dev/null +++ b/frontend/static/icons/1027.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1028.svg b/frontend/static/icons/1028.svg new file mode 100644 index 0000000..6369a48 --- /dev/null +++ b/frontend/static/icons/1028.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1029.svg b/frontend/static/icons/1029.svg new file mode 100644 index 0000000..178c3d0 --- /dev/null +++ b/frontend/static/icons/1029.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/103-fill.svg b/frontend/static/icons/103-fill.svg new file mode 100644 index 0000000..d629442 --- /dev/null +++ b/frontend/static/icons/103-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/103.svg b/frontend/static/icons/103.svg new file mode 100644 index 0000000..bbc124f --- /dev/null +++ b/frontend/static/icons/103.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1030.svg b/frontend/static/icons/1030.svg new file mode 100644 index 0000000..e9b8501 --- /dev/null +++ b/frontend/static/icons/1030.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1031.svg b/frontend/static/icons/1031.svg new file mode 100644 index 0000000..43f8d44 --- /dev/null +++ b/frontend/static/icons/1031.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1032.svg b/frontend/static/icons/1032.svg new file mode 100644 index 0000000..da53958 --- /dev/null +++ b/frontend/static/icons/1032.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1033.svg b/frontend/static/icons/1033.svg new file mode 100644 index 0000000..bb7787b --- /dev/null +++ b/frontend/static/icons/1033.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1034.svg b/frontend/static/icons/1034.svg new file mode 100644 index 0000000..c277460 --- /dev/null +++ b/frontend/static/icons/1034.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1035.svg b/frontend/static/icons/1035.svg new file mode 100644 index 0000000..a168b4c --- /dev/null +++ b/frontend/static/icons/1035.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1036.svg b/frontend/static/icons/1036.svg new file mode 100644 index 0000000..6437f27 --- /dev/null +++ b/frontend/static/icons/1036.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1037.svg b/frontend/static/icons/1037.svg new file mode 100644 index 0000000..db7dcef --- /dev/null +++ b/frontend/static/icons/1037.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1038.svg b/frontend/static/icons/1038.svg new file mode 100644 index 0000000..5bc07b1 --- /dev/null +++ b/frontend/static/icons/1038.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1039.svg b/frontend/static/icons/1039.svg new file mode 100644 index 0000000..3822b50 --- /dev/null +++ b/frontend/static/icons/1039.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/104-fill.svg b/frontend/static/icons/104-fill.svg new file mode 100644 index 0000000..02d8ee1 --- /dev/null +++ b/frontend/static/icons/104-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/104.svg b/frontend/static/icons/104.svg new file mode 100644 index 0000000..4c6fec8 --- /dev/null +++ b/frontend/static/icons/104.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1040.svg b/frontend/static/icons/1040.svg new file mode 100644 index 0000000..e4c9118 --- /dev/null +++ b/frontend/static/icons/1040.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1041.svg b/frontend/static/icons/1041.svg new file mode 100644 index 0000000..4746bee --- /dev/null +++ b/frontend/static/icons/1041.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1042.svg b/frontend/static/icons/1042.svg new file mode 100644 index 0000000..54015f7 --- /dev/null +++ b/frontend/static/icons/1042.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1043.svg b/frontend/static/icons/1043.svg new file mode 100644 index 0000000..4f04d54 --- /dev/null +++ b/frontend/static/icons/1043.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1044.svg b/frontend/static/icons/1044.svg new file mode 100644 index 0000000..0dad496 --- /dev/null +++ b/frontend/static/icons/1044.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1045.svg b/frontend/static/icons/1045.svg new file mode 100644 index 0000000..e3c7f50 --- /dev/null +++ b/frontend/static/icons/1045.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1046.svg b/frontend/static/icons/1046.svg new file mode 100644 index 0000000..53a56aa --- /dev/null +++ b/frontend/static/icons/1046.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1047.svg b/frontend/static/icons/1047.svg new file mode 100644 index 0000000..34cbaf0 --- /dev/null +++ b/frontend/static/icons/1047.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1048.svg b/frontend/static/icons/1048.svg new file mode 100644 index 0000000..a58beb9 --- /dev/null +++ b/frontend/static/icons/1048.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1049.svg b/frontend/static/icons/1049.svg new file mode 100644 index 0000000..5ffaa6a --- /dev/null +++ b/frontend/static/icons/1049.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1050.svg b/frontend/static/icons/1050.svg new file mode 100644 index 0000000..037e525 --- /dev/null +++ b/frontend/static/icons/1050.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1051.svg b/frontend/static/icons/1051.svg new file mode 100644 index 0000000..f0fd308 --- /dev/null +++ b/frontend/static/icons/1051.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1052.svg b/frontend/static/icons/1052.svg new file mode 100644 index 0000000..39d4662 --- /dev/null +++ b/frontend/static/icons/1052.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1053.svg b/frontend/static/icons/1053.svg new file mode 100644 index 0000000..d79c9f6 --- /dev/null +++ b/frontend/static/icons/1053.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1054.svg b/frontend/static/icons/1054.svg new file mode 100644 index 0000000..464c044 --- /dev/null +++ b/frontend/static/icons/1054.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1055.svg b/frontend/static/icons/1055.svg new file mode 100644 index 0000000..9d8315c --- /dev/null +++ b/frontend/static/icons/1055.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1056.svg b/frontend/static/icons/1056.svg new file mode 100644 index 0000000..b106fed --- /dev/null +++ b/frontend/static/icons/1056.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1057.svg b/frontend/static/icons/1057.svg new file mode 100644 index 0000000..b0a541e --- /dev/null +++ b/frontend/static/icons/1057.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1058.svg b/frontend/static/icons/1058.svg new file mode 100644 index 0000000..aea2b8f --- /dev/null +++ b/frontend/static/icons/1058.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1059.svg b/frontend/static/icons/1059.svg new file mode 100644 index 0000000..d17130e --- /dev/null +++ b/frontend/static/icons/1059.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1060.svg b/frontend/static/icons/1060.svg new file mode 100644 index 0000000..1133fd3 --- /dev/null +++ b/frontend/static/icons/1060.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1061.svg b/frontend/static/icons/1061.svg new file mode 100644 index 0000000..4c4adf2 --- /dev/null +++ b/frontend/static/icons/1061.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1062.svg b/frontend/static/icons/1062.svg new file mode 100644 index 0000000..34161da --- /dev/null +++ b/frontend/static/icons/1062.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1063.svg b/frontend/static/icons/1063.svg new file mode 100644 index 0000000..ec0e1b6 --- /dev/null +++ b/frontend/static/icons/1063.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1064.svg b/frontend/static/icons/1064.svg new file mode 100644 index 0000000..ae669d9 --- /dev/null +++ b/frontend/static/icons/1064.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1065.svg b/frontend/static/icons/1065.svg new file mode 100644 index 0000000..98f95e5 --- /dev/null +++ b/frontend/static/icons/1065.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1066.svg b/frontend/static/icons/1066.svg new file mode 100644 index 0000000..1883b60 --- /dev/null +++ b/frontend/static/icons/1066.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1067.svg b/frontend/static/icons/1067.svg new file mode 100644 index 0000000..d2fde3e --- /dev/null +++ b/frontend/static/icons/1067.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1068.svg b/frontend/static/icons/1068.svg new file mode 100644 index 0000000..3feab39 --- /dev/null +++ b/frontend/static/icons/1068.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1069.svg b/frontend/static/icons/1069.svg new file mode 100644 index 0000000..a4680fa --- /dev/null +++ b/frontend/static/icons/1069.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1071.svg b/frontend/static/icons/1071.svg new file mode 100644 index 0000000..c27ced5 --- /dev/null +++ b/frontend/static/icons/1071.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1072.svg b/frontend/static/icons/1072.svg new file mode 100644 index 0000000..5a382fe --- /dev/null +++ b/frontend/static/icons/1072.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1073.svg b/frontend/static/icons/1073.svg new file mode 100644 index 0000000..415717d --- /dev/null +++ b/frontend/static/icons/1073.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1074.svg b/frontend/static/icons/1074.svg new file mode 100644 index 0000000..daa1694 --- /dev/null +++ b/frontend/static/icons/1074.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1075.svg b/frontend/static/icons/1075.svg new file mode 100644 index 0000000..423056b --- /dev/null +++ b/frontend/static/icons/1075.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1076.svg b/frontend/static/icons/1076.svg new file mode 100644 index 0000000..3cfd9ba --- /dev/null +++ b/frontend/static/icons/1076.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1077.svg b/frontend/static/icons/1077.svg new file mode 100644 index 0000000..0c69bd8 --- /dev/null +++ b/frontend/static/icons/1077.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1078.svg b/frontend/static/icons/1078.svg new file mode 100644 index 0000000..0f1a9e0 --- /dev/null +++ b/frontend/static/icons/1078.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1079.svg b/frontend/static/icons/1079.svg new file mode 100644 index 0000000..55b72f8 --- /dev/null +++ b/frontend/static/icons/1079.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1080.svg b/frontend/static/icons/1080.svg new file mode 100644 index 0000000..72f8233 --- /dev/null +++ b/frontend/static/icons/1080.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1081.svg b/frontend/static/icons/1081.svg new file mode 100644 index 0000000..a9a9a8f --- /dev/null +++ b/frontend/static/icons/1081.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1082.svg b/frontend/static/icons/1082.svg new file mode 100644 index 0000000..ad1de1a --- /dev/null +++ b/frontend/static/icons/1082.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1084.svg b/frontend/static/icons/1084.svg new file mode 100644 index 0000000..3335919 --- /dev/null +++ b/frontend/static/icons/1084.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1085.svg b/frontend/static/icons/1085.svg new file mode 100644 index 0000000..70e0797 --- /dev/null +++ b/frontend/static/icons/1085.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1086.svg b/frontend/static/icons/1086.svg new file mode 100644 index 0000000..7848e85 --- /dev/null +++ b/frontend/static/icons/1086.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1087.svg b/frontend/static/icons/1087.svg new file mode 100644 index 0000000..f54d9f8 --- /dev/null +++ b/frontend/static/icons/1087.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1088.svg b/frontend/static/icons/1088.svg new file mode 100644 index 0000000..dcdb94f --- /dev/null +++ b/frontend/static/icons/1088.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1089.svg b/frontend/static/icons/1089.svg new file mode 100644 index 0000000..073fcc6 --- /dev/null +++ b/frontend/static/icons/1089.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1201.svg b/frontend/static/icons/1201.svg new file mode 100644 index 0000000..a354837 --- /dev/null +++ b/frontend/static/icons/1201.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1202.svg b/frontend/static/icons/1202.svg new file mode 100644 index 0000000..eccdcef --- /dev/null +++ b/frontend/static/icons/1202.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1203.svg b/frontend/static/icons/1203.svg new file mode 100644 index 0000000..39db56e --- /dev/null +++ b/frontend/static/icons/1203.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1204.svg b/frontend/static/icons/1204.svg new file mode 100644 index 0000000..d895006 --- /dev/null +++ b/frontend/static/icons/1204.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1205.svg b/frontend/static/icons/1205.svg new file mode 100644 index 0000000..77c8f22 --- /dev/null +++ b/frontend/static/icons/1205.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1206.svg b/frontend/static/icons/1206.svg new file mode 100644 index 0000000..890a97f --- /dev/null +++ b/frontend/static/icons/1206.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1207.svg b/frontend/static/icons/1207.svg new file mode 100644 index 0000000..51a2e30 --- /dev/null +++ b/frontend/static/icons/1207.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1208.svg b/frontend/static/icons/1208.svg new file mode 100644 index 0000000..a8c10e0 --- /dev/null +++ b/frontend/static/icons/1208.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1209.svg b/frontend/static/icons/1209.svg new file mode 100644 index 0000000..095caf5 --- /dev/null +++ b/frontend/static/icons/1209.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1210.svg b/frontend/static/icons/1210.svg new file mode 100644 index 0000000..2d9ceea --- /dev/null +++ b/frontend/static/icons/1210.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1211.svg b/frontend/static/icons/1211.svg new file mode 100644 index 0000000..2a1b965 --- /dev/null +++ b/frontend/static/icons/1211.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1212.svg b/frontend/static/icons/1212.svg new file mode 100644 index 0000000..781c77e --- /dev/null +++ b/frontend/static/icons/1212.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1213.svg b/frontend/static/icons/1213.svg new file mode 100644 index 0000000..943fdbe --- /dev/null +++ b/frontend/static/icons/1213.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1214.svg b/frontend/static/icons/1214.svg new file mode 100644 index 0000000..6193798 --- /dev/null +++ b/frontend/static/icons/1214.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1215.svg b/frontend/static/icons/1215.svg new file mode 100644 index 0000000..9cc0964 --- /dev/null +++ b/frontend/static/icons/1215.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1216.svg b/frontend/static/icons/1216.svg new file mode 100644 index 0000000..7d92e74 --- /dev/null +++ b/frontend/static/icons/1216.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1217.svg b/frontend/static/icons/1217.svg new file mode 100644 index 0000000..275a382 --- /dev/null +++ b/frontend/static/icons/1217.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1218.svg b/frontend/static/icons/1218.svg new file mode 100644 index 0000000..3b5fb1e --- /dev/null +++ b/frontend/static/icons/1218.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1219.svg b/frontend/static/icons/1219.svg new file mode 100644 index 0000000..6467fd9 --- /dev/null +++ b/frontend/static/icons/1219.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1221.svg b/frontend/static/icons/1221.svg new file mode 100644 index 0000000..a4532ad --- /dev/null +++ b/frontend/static/icons/1221.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1241.svg b/frontend/static/icons/1241.svg new file mode 100644 index 0000000..354ff65 --- /dev/null +++ b/frontend/static/icons/1241.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1242.svg b/frontend/static/icons/1242.svg new file mode 100644 index 0000000..d9d61bd --- /dev/null +++ b/frontend/static/icons/1242.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1243.svg b/frontend/static/icons/1243.svg new file mode 100644 index 0000000..19c10b5 --- /dev/null +++ b/frontend/static/icons/1243.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1244.svg b/frontend/static/icons/1244.svg new file mode 100644 index 0000000..cd74ff5 --- /dev/null +++ b/frontend/static/icons/1244.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1245.svg b/frontend/static/icons/1245.svg new file mode 100644 index 0000000..e39e90b --- /dev/null +++ b/frontend/static/icons/1245.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1246.svg b/frontend/static/icons/1246.svg new file mode 100644 index 0000000..a56c005 --- /dev/null +++ b/frontend/static/icons/1246.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1247.svg b/frontend/static/icons/1247.svg new file mode 100644 index 0000000..a064864 --- /dev/null +++ b/frontend/static/icons/1247.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1248.svg b/frontend/static/icons/1248.svg new file mode 100644 index 0000000..e89ad75 --- /dev/null +++ b/frontend/static/icons/1248.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1249.svg b/frontend/static/icons/1249.svg new file mode 100644 index 0000000..506e4bd --- /dev/null +++ b/frontend/static/icons/1249.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1250.svg b/frontend/static/icons/1250.svg new file mode 100644 index 0000000..886d7a6 --- /dev/null +++ b/frontend/static/icons/1250.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1251.svg b/frontend/static/icons/1251.svg new file mode 100644 index 0000000..46acf7d --- /dev/null +++ b/frontend/static/icons/1251.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1271.svg b/frontend/static/icons/1271.svg new file mode 100644 index 0000000..91e62d9 --- /dev/null +++ b/frontend/static/icons/1271.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1272.svg b/frontend/static/icons/1272.svg new file mode 100644 index 0000000..2a7bca4 --- /dev/null +++ b/frontend/static/icons/1272.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1273.svg b/frontend/static/icons/1273.svg new file mode 100644 index 0000000..aff1cc8 --- /dev/null +++ b/frontend/static/icons/1273.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1274.svg b/frontend/static/icons/1274.svg new file mode 100644 index 0000000..cf9db92 --- /dev/null +++ b/frontend/static/icons/1274.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/150-fill.svg b/frontend/static/icons/150-fill.svg new file mode 100644 index 0000000..18b4fc0 --- /dev/null +++ b/frontend/static/icons/150-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/150.svg b/frontend/static/icons/150.svg new file mode 100644 index 0000000..3b78903 --- /dev/null +++ b/frontend/static/icons/150.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/151-fill.svg b/frontend/static/icons/151-fill.svg new file mode 100644 index 0000000..f0ac138 --- /dev/null +++ b/frontend/static/icons/151-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/151.svg b/frontend/static/icons/151.svg new file mode 100644 index 0000000..48b4502 --- /dev/null +++ b/frontend/static/icons/151.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/152-fill.svg b/frontend/static/icons/152-fill.svg new file mode 100644 index 0000000..d26dada --- /dev/null +++ b/frontend/static/icons/152-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/152.svg b/frontend/static/icons/152.svg new file mode 100644 index 0000000..091390d --- /dev/null +++ b/frontend/static/icons/152.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/153-fill.svg b/frontend/static/icons/153-fill.svg new file mode 100644 index 0000000..daa1a1e --- /dev/null +++ b/frontend/static/icons/153-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/153.svg b/frontend/static/icons/153.svg new file mode 100644 index 0000000..052f33f --- /dev/null +++ b/frontend/static/icons/153.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1601.svg b/frontend/static/icons/1601.svg new file mode 100644 index 0000000..7fe3c0e --- /dev/null +++ b/frontend/static/icons/1601.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1602.svg b/frontend/static/icons/1602.svg new file mode 100644 index 0000000..2761af8 --- /dev/null +++ b/frontend/static/icons/1602.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1603.svg b/frontend/static/icons/1603.svg new file mode 100644 index 0000000..890ed8f --- /dev/null +++ b/frontend/static/icons/1603.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1604.svg b/frontend/static/icons/1604.svg new file mode 100644 index 0000000..18d5ecb --- /dev/null +++ b/frontend/static/icons/1604.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1605.svg b/frontend/static/icons/1605.svg new file mode 100644 index 0000000..a1158fd --- /dev/null +++ b/frontend/static/icons/1605.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1606.svg b/frontend/static/icons/1606.svg new file mode 100644 index 0000000..8441735 --- /dev/null +++ b/frontend/static/icons/1606.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1607.svg b/frontend/static/icons/1607.svg new file mode 100644 index 0000000..45662d8 --- /dev/null +++ b/frontend/static/icons/1607.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1608.svg b/frontend/static/icons/1608.svg new file mode 100644 index 0000000..ca87c16 --- /dev/null +++ b/frontend/static/icons/1608.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1609.svg b/frontend/static/icons/1609.svg new file mode 100644 index 0000000..235ab40 --- /dev/null +++ b/frontend/static/icons/1609.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1610.svg b/frontend/static/icons/1610.svg new file mode 100644 index 0000000..cd61c4a --- /dev/null +++ b/frontend/static/icons/1610.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1701.svg b/frontend/static/icons/1701.svg new file mode 100644 index 0000000..db7b60f --- /dev/null +++ b/frontend/static/icons/1701.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1702.svg b/frontend/static/icons/1702.svg new file mode 100644 index 0000000..b2ad066 --- /dev/null +++ b/frontend/static/icons/1702.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1703.svg b/frontend/static/icons/1703.svg new file mode 100644 index 0000000..aeaa7b5 --- /dev/null +++ b/frontend/static/icons/1703.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1704.svg b/frontend/static/icons/1704.svg new file mode 100644 index 0000000..bfe0106 --- /dev/null +++ b/frontend/static/icons/1704.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1705.svg b/frontend/static/icons/1705.svg new file mode 100644 index 0000000..ab6c0c2 --- /dev/null +++ b/frontend/static/icons/1705.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1706.svg b/frontend/static/icons/1706.svg new file mode 100644 index 0000000..f292a63 --- /dev/null +++ b/frontend/static/icons/1706.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1707.svg b/frontend/static/icons/1707.svg new file mode 100644 index 0000000..aeb9c51 --- /dev/null +++ b/frontend/static/icons/1707.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1708.svg b/frontend/static/icons/1708.svg new file mode 100644 index 0000000..20eff2d --- /dev/null +++ b/frontend/static/icons/1708.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/1709.svg b/frontend/static/icons/1709.svg new file mode 100644 index 0000000..e9d2660 --- /dev/null +++ b/frontend/static/icons/1709.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1710.svg b/frontend/static/icons/1710.svg new file mode 100644 index 0000000..7d65071 --- /dev/null +++ b/frontend/static/icons/1710.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/1801.svg b/frontend/static/icons/1801.svg new file mode 100644 index 0000000..16d1e6c --- /dev/null +++ b/frontend/static/icons/1801.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1802.svg b/frontend/static/icons/1802.svg new file mode 100644 index 0000000..55c9185 --- /dev/null +++ b/frontend/static/icons/1802.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1803.svg b/frontend/static/icons/1803.svg new file mode 100644 index 0000000..f363401 --- /dev/null +++ b/frontend/static/icons/1803.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1804.svg b/frontend/static/icons/1804.svg new file mode 100644 index 0000000..392f65c --- /dev/null +++ b/frontend/static/icons/1804.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/1805.svg b/frontend/static/icons/1805.svg new file mode 100644 index 0000000..8452bed --- /dev/null +++ b/frontend/static/icons/1805.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2001.svg b/frontend/static/icons/2001.svg new file mode 100644 index 0000000..1c074ca --- /dev/null +++ b/frontend/static/icons/2001.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2002.svg b/frontend/static/icons/2002.svg new file mode 100644 index 0000000..fa41f55 --- /dev/null +++ b/frontend/static/icons/2002.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2003.svg b/frontend/static/icons/2003.svg new file mode 100644 index 0000000..0db57c9 --- /dev/null +++ b/frontend/static/icons/2003.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2004.svg b/frontend/static/icons/2004.svg new file mode 100644 index 0000000..6ec30d6 --- /dev/null +++ b/frontend/static/icons/2004.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2005.svg b/frontend/static/icons/2005.svg new file mode 100644 index 0000000..234edda --- /dev/null +++ b/frontend/static/icons/2005.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2006.svg b/frontend/static/icons/2006.svg new file mode 100644 index 0000000..aa7ad0a --- /dev/null +++ b/frontend/static/icons/2006.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2007.svg b/frontend/static/icons/2007.svg new file mode 100644 index 0000000..449a3c4 --- /dev/null +++ b/frontend/static/icons/2007.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2029.svg b/frontend/static/icons/2029.svg new file mode 100644 index 0000000..7e0c67c --- /dev/null +++ b/frontend/static/icons/2029.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2030.svg b/frontend/static/icons/2030.svg new file mode 100644 index 0000000..59d6e5a --- /dev/null +++ b/frontend/static/icons/2030.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2031.svg b/frontend/static/icons/2031.svg new file mode 100644 index 0000000..a517e94 --- /dev/null +++ b/frontend/static/icons/2031.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2032.svg b/frontend/static/icons/2032.svg new file mode 100644 index 0000000..05848d8 --- /dev/null +++ b/frontend/static/icons/2032.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2033.svg b/frontend/static/icons/2033.svg new file mode 100644 index 0000000..815c98c --- /dev/null +++ b/frontend/static/icons/2033.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2050.svg b/frontend/static/icons/2050.svg new file mode 100644 index 0000000..393e8b2 --- /dev/null +++ b/frontend/static/icons/2050.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2051.svg b/frontend/static/icons/2051.svg new file mode 100644 index 0000000..b8e6133 --- /dev/null +++ b/frontend/static/icons/2051.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2052.svg b/frontend/static/icons/2052.svg new file mode 100644 index 0000000..6a68373 --- /dev/null +++ b/frontend/static/icons/2052.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2053.svg b/frontend/static/icons/2053.svg new file mode 100644 index 0000000..85417c2 --- /dev/null +++ b/frontend/static/icons/2053.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2054.svg b/frontend/static/icons/2054.svg new file mode 100644 index 0000000..040dbc3 --- /dev/null +++ b/frontend/static/icons/2054.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2070.svg b/frontend/static/icons/2070.svg new file mode 100644 index 0000000..8ff2960 --- /dev/null +++ b/frontend/static/icons/2070.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2071.svg b/frontend/static/icons/2071.svg new file mode 100644 index 0000000..090b0aa --- /dev/null +++ b/frontend/static/icons/2071.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2072.svg b/frontend/static/icons/2072.svg new file mode 100644 index 0000000..8fd9e7c --- /dev/null +++ b/frontend/static/icons/2072.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2073.svg b/frontend/static/icons/2073.svg new file mode 100644 index 0000000..0f1cc40 --- /dev/null +++ b/frontend/static/icons/2073.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2074.svg b/frontend/static/icons/2074.svg new file mode 100644 index 0000000..87ffaa3 --- /dev/null +++ b/frontend/static/icons/2074.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2075.svg b/frontend/static/icons/2075.svg new file mode 100644 index 0000000..243f0e1 --- /dev/null +++ b/frontend/static/icons/2075.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2076.svg b/frontend/static/icons/2076.svg new file mode 100644 index 0000000..2cde19f --- /dev/null +++ b/frontend/static/icons/2076.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2077.svg b/frontend/static/icons/2077.svg new file mode 100644 index 0000000..7587cf9 --- /dev/null +++ b/frontend/static/icons/2077.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2078.svg b/frontend/static/icons/2078.svg new file mode 100644 index 0000000..993893d --- /dev/null +++ b/frontend/static/icons/2078.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2079.svg b/frontend/static/icons/2079.svg new file mode 100644 index 0000000..9f60056 --- /dev/null +++ b/frontend/static/icons/2079.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2080.svg b/frontend/static/icons/2080.svg new file mode 100644 index 0000000..19d181d --- /dev/null +++ b/frontend/static/icons/2080.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2081.svg b/frontend/static/icons/2081.svg new file mode 100644 index 0000000..dc016b3 --- /dev/null +++ b/frontend/static/icons/2081.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2082.svg b/frontend/static/icons/2082.svg new file mode 100644 index 0000000..b46b251 --- /dev/null +++ b/frontend/static/icons/2082.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2083.svg b/frontend/static/icons/2083.svg new file mode 100644 index 0000000..dd730aa --- /dev/null +++ b/frontend/static/icons/2083.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2084.svg b/frontend/static/icons/2084.svg new file mode 100644 index 0000000..fe9aaf4 --- /dev/null +++ b/frontend/static/icons/2084.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2085.svg b/frontend/static/icons/2085.svg new file mode 100644 index 0000000..5ad4117 --- /dev/null +++ b/frontend/static/icons/2085.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2100.svg b/frontend/static/icons/2100.svg new file mode 100644 index 0000000..06a3379 --- /dev/null +++ b/frontend/static/icons/2100.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2101.svg b/frontend/static/icons/2101.svg new file mode 100644 index 0000000..dc1e4f7 --- /dev/null +++ b/frontend/static/icons/2101.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2102.svg b/frontend/static/icons/2102.svg new file mode 100644 index 0000000..c669238 --- /dev/null +++ b/frontend/static/icons/2102.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2103.svg b/frontend/static/icons/2103.svg new file mode 100644 index 0000000..69142bb --- /dev/null +++ b/frontend/static/icons/2103.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2104.svg b/frontend/static/icons/2104.svg new file mode 100644 index 0000000..c17aaf3 --- /dev/null +++ b/frontend/static/icons/2104.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2105.svg b/frontend/static/icons/2105.svg new file mode 100644 index 0000000..4bd7f83 --- /dev/null +++ b/frontend/static/icons/2105.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2106.svg b/frontend/static/icons/2106.svg new file mode 100644 index 0000000..6d2b09f --- /dev/null +++ b/frontend/static/icons/2106.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2107.svg b/frontend/static/icons/2107.svg new file mode 100644 index 0000000..ee9602b --- /dev/null +++ b/frontend/static/icons/2107.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2108.svg b/frontend/static/icons/2108.svg new file mode 100644 index 0000000..1bc2bd6 --- /dev/null +++ b/frontend/static/icons/2108.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2109.svg b/frontend/static/icons/2109.svg new file mode 100644 index 0000000..0fc99a2 --- /dev/null +++ b/frontend/static/icons/2109.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2111.svg b/frontend/static/icons/2111.svg new file mode 100644 index 0000000..73e82ae --- /dev/null +++ b/frontend/static/icons/2111.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2120.svg b/frontend/static/icons/2120.svg new file mode 100644 index 0000000..d3b8b5d --- /dev/null +++ b/frontend/static/icons/2120.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2121.svg b/frontend/static/icons/2121.svg new file mode 100644 index 0000000..d282d3d --- /dev/null +++ b/frontend/static/icons/2121.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2122.svg b/frontend/static/icons/2122.svg new file mode 100644 index 0000000..80c4740 --- /dev/null +++ b/frontend/static/icons/2122.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2123.svg b/frontend/static/icons/2123.svg new file mode 100644 index 0000000..39b692e --- /dev/null +++ b/frontend/static/icons/2123.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2124.svg b/frontend/static/icons/2124.svg new file mode 100644 index 0000000..edba0a2 --- /dev/null +++ b/frontend/static/icons/2124.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2125.svg b/frontend/static/icons/2125.svg new file mode 100644 index 0000000..8eece54 --- /dev/null +++ b/frontend/static/icons/2125.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2126.svg b/frontend/static/icons/2126.svg new file mode 100644 index 0000000..abbf223 --- /dev/null +++ b/frontend/static/icons/2126.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2127.svg b/frontend/static/icons/2127.svg new file mode 100644 index 0000000..298108f --- /dev/null +++ b/frontend/static/icons/2127.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2128.svg b/frontend/static/icons/2128.svg new file mode 100644 index 0000000..843a5eb --- /dev/null +++ b/frontend/static/icons/2128.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2129.svg b/frontend/static/icons/2129.svg new file mode 100644 index 0000000..addaf56 --- /dev/null +++ b/frontend/static/icons/2129.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2130.svg b/frontend/static/icons/2130.svg new file mode 100644 index 0000000..df294f1 --- /dev/null +++ b/frontend/static/icons/2130.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2131.svg b/frontend/static/icons/2131.svg new file mode 100644 index 0000000..265c5f6 --- /dev/null +++ b/frontend/static/icons/2131.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2132.svg b/frontend/static/icons/2132.svg new file mode 100644 index 0000000..f0e5344 --- /dev/null +++ b/frontend/static/icons/2132.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2133.svg b/frontend/static/icons/2133.svg new file mode 100644 index 0000000..dd1aec0 --- /dev/null +++ b/frontend/static/icons/2133.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2134.svg b/frontend/static/icons/2134.svg new file mode 100644 index 0000000..a64d0b2 --- /dev/null +++ b/frontend/static/icons/2134.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2135.svg b/frontend/static/icons/2135.svg new file mode 100644 index 0000000..9bd2ae5 --- /dev/null +++ b/frontend/static/icons/2135.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2150.svg b/frontend/static/icons/2150.svg new file mode 100644 index 0000000..0f29ad5 --- /dev/null +++ b/frontend/static/icons/2150.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2151.svg b/frontend/static/icons/2151.svg new file mode 100644 index 0000000..78a04a9 --- /dev/null +++ b/frontend/static/icons/2151.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2152.svg b/frontend/static/icons/2152.svg new file mode 100644 index 0000000..be19f65 --- /dev/null +++ b/frontend/static/icons/2152.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2153.svg b/frontend/static/icons/2153.svg new file mode 100644 index 0000000..c2a0f29 --- /dev/null +++ b/frontend/static/icons/2153.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2154.svg b/frontend/static/icons/2154.svg new file mode 100644 index 0000000..5dd08ee --- /dev/null +++ b/frontend/static/icons/2154.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2155.svg b/frontend/static/icons/2155.svg new file mode 100644 index 0000000..ea4bf54 --- /dev/null +++ b/frontend/static/icons/2155.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2156.svg b/frontend/static/icons/2156.svg new file mode 100644 index 0000000..7e8a21f --- /dev/null +++ b/frontend/static/icons/2156.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2157.svg b/frontend/static/icons/2157.svg new file mode 100644 index 0000000..e1b358a --- /dev/null +++ b/frontend/static/icons/2157.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2158.svg b/frontend/static/icons/2158.svg new file mode 100644 index 0000000..78649a9 --- /dev/null +++ b/frontend/static/icons/2158.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2159.svg b/frontend/static/icons/2159.svg new file mode 100644 index 0000000..cdc4d82 --- /dev/null +++ b/frontend/static/icons/2159.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2160.svg b/frontend/static/icons/2160.svg new file mode 100644 index 0000000..50af78c --- /dev/null +++ b/frontend/static/icons/2160.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2161.svg b/frontend/static/icons/2161.svg new file mode 100644 index 0000000..10d94a6 --- /dev/null +++ b/frontend/static/icons/2161.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2162.svg b/frontend/static/icons/2162.svg new file mode 100644 index 0000000..bee6977 --- /dev/null +++ b/frontend/static/icons/2162.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2163.svg b/frontend/static/icons/2163.svg new file mode 100644 index 0000000..3917621 --- /dev/null +++ b/frontend/static/icons/2163.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2164.svg b/frontend/static/icons/2164.svg new file mode 100644 index 0000000..37cab87 --- /dev/null +++ b/frontend/static/icons/2164.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2165.svg b/frontend/static/icons/2165.svg new file mode 100644 index 0000000..c9d3df8 --- /dev/null +++ b/frontend/static/icons/2165.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2166.svg b/frontend/static/icons/2166.svg new file mode 100644 index 0000000..da8ad78 --- /dev/null +++ b/frontend/static/icons/2166.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2167.svg b/frontend/static/icons/2167.svg new file mode 100644 index 0000000..9cf296c --- /dev/null +++ b/frontend/static/icons/2167.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2190.svg b/frontend/static/icons/2190.svg new file mode 100644 index 0000000..80584de --- /dev/null +++ b/frontend/static/icons/2190.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2191.svg b/frontend/static/icons/2191.svg new file mode 100644 index 0000000..eea7488 --- /dev/null +++ b/frontend/static/icons/2191.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2192.svg b/frontend/static/icons/2192.svg new file mode 100644 index 0000000..6b43507 --- /dev/null +++ b/frontend/static/icons/2192.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2193.svg b/frontend/static/icons/2193.svg new file mode 100644 index 0000000..5c4a9d0 --- /dev/null +++ b/frontend/static/icons/2193.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2200.svg b/frontend/static/icons/2200.svg new file mode 100644 index 0000000..4670433 --- /dev/null +++ b/frontend/static/icons/2200.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2201.svg b/frontend/static/icons/2201.svg new file mode 100644 index 0000000..6efab12 --- /dev/null +++ b/frontend/static/icons/2201.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2202.svg b/frontend/static/icons/2202.svg new file mode 100644 index 0000000..e58a023 --- /dev/null +++ b/frontend/static/icons/2202.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2203.svg b/frontend/static/icons/2203.svg new file mode 100644 index 0000000..ef22be7 --- /dev/null +++ b/frontend/static/icons/2203.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2204.svg b/frontend/static/icons/2204.svg new file mode 100644 index 0000000..633600d --- /dev/null +++ b/frontend/static/icons/2204.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2205.svg b/frontend/static/icons/2205.svg new file mode 100644 index 0000000..487da31 --- /dev/null +++ b/frontend/static/icons/2205.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2207.svg b/frontend/static/icons/2207.svg new file mode 100644 index 0000000..e633e5b --- /dev/null +++ b/frontend/static/icons/2207.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2208.svg b/frontend/static/icons/2208.svg new file mode 100644 index 0000000..03da20d --- /dev/null +++ b/frontend/static/icons/2208.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2209.svg b/frontend/static/icons/2209.svg new file mode 100644 index 0000000..1b77a50 --- /dev/null +++ b/frontend/static/icons/2209.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2210.svg b/frontend/static/icons/2210.svg new file mode 100644 index 0000000..23aec9b --- /dev/null +++ b/frontend/static/icons/2210.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2211.svg b/frontend/static/icons/2211.svg new file mode 100644 index 0000000..2c05724 --- /dev/null +++ b/frontend/static/icons/2211.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2212.svg b/frontend/static/icons/2212.svg new file mode 100644 index 0000000..0bc1621 --- /dev/null +++ b/frontend/static/icons/2212.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2213.svg b/frontend/static/icons/2213.svg new file mode 100644 index 0000000..d4ff0e3 --- /dev/null +++ b/frontend/static/icons/2213.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2214.svg b/frontend/static/icons/2214.svg new file mode 100644 index 0000000..ce6daa6 --- /dev/null +++ b/frontend/static/icons/2214.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2215.svg b/frontend/static/icons/2215.svg new file mode 100644 index 0000000..3b00639 --- /dev/null +++ b/frontend/static/icons/2215.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2216.svg b/frontend/static/icons/2216.svg new file mode 100644 index 0000000..88c40d7 --- /dev/null +++ b/frontend/static/icons/2216.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2217.svg b/frontend/static/icons/2217.svg new file mode 100644 index 0000000..98dc35b --- /dev/null +++ b/frontend/static/icons/2217.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2218.svg b/frontend/static/icons/2218.svg new file mode 100644 index 0000000..833ab49 --- /dev/null +++ b/frontend/static/icons/2218.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2300.svg b/frontend/static/icons/2300.svg new file mode 100644 index 0000000..dbf3bec --- /dev/null +++ b/frontend/static/icons/2300.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2301.svg b/frontend/static/icons/2301.svg new file mode 100644 index 0000000..1bd72fa --- /dev/null +++ b/frontend/static/icons/2301.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2302.svg b/frontend/static/icons/2302.svg new file mode 100644 index 0000000..87df1b4 --- /dev/null +++ b/frontend/static/icons/2302.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2303.svg b/frontend/static/icons/2303.svg new file mode 100644 index 0000000..a0a5dab --- /dev/null +++ b/frontend/static/icons/2303.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2304.svg b/frontend/static/icons/2304.svg new file mode 100644 index 0000000..4a3f01e --- /dev/null +++ b/frontend/static/icons/2304.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2305.svg b/frontend/static/icons/2305.svg new file mode 100644 index 0000000..73a797f --- /dev/null +++ b/frontend/static/icons/2305.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2306.svg b/frontend/static/icons/2306.svg new file mode 100644 index 0000000..31631aa --- /dev/null +++ b/frontend/static/icons/2306.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2307.svg b/frontend/static/icons/2307.svg new file mode 100644 index 0000000..1ec69e7 --- /dev/null +++ b/frontend/static/icons/2307.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2308.svg b/frontend/static/icons/2308.svg new file mode 100644 index 0000000..32d9db1 --- /dev/null +++ b/frontend/static/icons/2308.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2309.svg b/frontend/static/icons/2309.svg new file mode 100644 index 0000000..21640bb --- /dev/null +++ b/frontend/static/icons/2309.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2311.svg b/frontend/static/icons/2311.svg new file mode 100644 index 0000000..ae172f5 --- /dev/null +++ b/frontend/static/icons/2311.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2312.svg b/frontend/static/icons/2312.svg new file mode 100644 index 0000000..c5e5320 --- /dev/null +++ b/frontend/static/icons/2312.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2313.svg b/frontend/static/icons/2313.svg new file mode 100644 index 0000000..ac90056 --- /dev/null +++ b/frontend/static/icons/2313.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2314.svg b/frontend/static/icons/2314.svg new file mode 100644 index 0000000..10e7fb6 --- /dev/null +++ b/frontend/static/icons/2314.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2315.svg b/frontend/static/icons/2315.svg new file mode 100644 index 0000000..32f99f6 --- /dev/null +++ b/frontend/static/icons/2315.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2316.svg b/frontend/static/icons/2316.svg new file mode 100644 index 0000000..4841bdd --- /dev/null +++ b/frontend/static/icons/2316.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2317.svg b/frontend/static/icons/2317.svg new file mode 100644 index 0000000..903299a --- /dev/null +++ b/frontend/static/icons/2317.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2318.svg b/frontend/static/icons/2318.svg new file mode 100644 index 0000000..eb3667a --- /dev/null +++ b/frontend/static/icons/2318.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2319.svg b/frontend/static/icons/2319.svg new file mode 100644 index 0000000..c3e387e --- /dev/null +++ b/frontend/static/icons/2319.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2320.svg b/frontend/static/icons/2320.svg new file mode 100644 index 0000000..685bebf --- /dev/null +++ b/frontend/static/icons/2320.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2321.svg b/frontend/static/icons/2321.svg new file mode 100644 index 0000000..91ee16c --- /dev/null +++ b/frontend/static/icons/2321.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2322.svg b/frontend/static/icons/2322.svg new file mode 100644 index 0000000..bee58cd --- /dev/null +++ b/frontend/static/icons/2322.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2323.svg b/frontend/static/icons/2323.svg new file mode 100644 index 0000000..0480365 --- /dev/null +++ b/frontend/static/icons/2323.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2324.svg b/frontend/static/icons/2324.svg new file mode 100644 index 0000000..7d9db6d --- /dev/null +++ b/frontend/static/icons/2324.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2325.svg b/frontend/static/icons/2325.svg new file mode 100644 index 0000000..57c8084 --- /dev/null +++ b/frontend/static/icons/2325.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2326.svg b/frontend/static/icons/2326.svg new file mode 100644 index 0000000..9d99f04 --- /dev/null +++ b/frontend/static/icons/2326.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2327.svg b/frontend/static/icons/2327.svg new file mode 100644 index 0000000..8e68119 --- /dev/null +++ b/frontend/static/icons/2327.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2328.svg b/frontend/static/icons/2328.svg new file mode 100644 index 0000000..252677e --- /dev/null +++ b/frontend/static/icons/2328.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2330.svg b/frontend/static/icons/2330.svg new file mode 100644 index 0000000..474c74d --- /dev/null +++ b/frontend/static/icons/2330.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2331.svg b/frontend/static/icons/2331.svg new file mode 100644 index 0000000..f00559b --- /dev/null +++ b/frontend/static/icons/2331.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2332.svg b/frontend/static/icons/2332.svg new file mode 100644 index 0000000..5d95372 --- /dev/null +++ b/frontend/static/icons/2332.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2333.svg b/frontend/static/icons/2333.svg new file mode 100644 index 0000000..58ed7da --- /dev/null +++ b/frontend/static/icons/2333.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2341.svg b/frontend/static/icons/2341.svg new file mode 100644 index 0000000..185eef0 --- /dev/null +++ b/frontend/static/icons/2341.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2343.svg b/frontend/static/icons/2343.svg new file mode 100644 index 0000000..d44a28a --- /dev/null +++ b/frontend/static/icons/2343.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2345.svg b/frontend/static/icons/2345.svg new file mode 100644 index 0000000..8d3ada2 --- /dev/null +++ b/frontend/static/icons/2345.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2346.svg b/frontend/static/icons/2346.svg new file mode 100644 index 0000000..4607eb1 --- /dev/null +++ b/frontend/static/icons/2346.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2348.svg b/frontend/static/icons/2348.svg new file mode 100644 index 0000000..1b6944e --- /dev/null +++ b/frontend/static/icons/2348.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2349.svg b/frontend/static/icons/2349.svg new file mode 100644 index 0000000..386a3e8 --- /dev/null +++ b/frontend/static/icons/2349.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2350.svg b/frontend/static/icons/2350.svg new file mode 100644 index 0000000..7c05490 --- /dev/null +++ b/frontend/static/icons/2350.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2351.svg b/frontend/static/icons/2351.svg new file mode 100644 index 0000000..74b5eb8 --- /dev/null +++ b/frontend/static/icons/2351.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2352.svg b/frontend/static/icons/2352.svg new file mode 100644 index 0000000..9924426 --- /dev/null +++ b/frontend/static/icons/2352.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2353.svg b/frontend/static/icons/2353.svg new file mode 100644 index 0000000..fb480b4 --- /dev/null +++ b/frontend/static/icons/2353.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2354.svg b/frontend/static/icons/2354.svg new file mode 100644 index 0000000..1c92250 --- /dev/null +++ b/frontend/static/icons/2354.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2355.svg b/frontend/static/icons/2355.svg new file mode 100644 index 0000000..92b8540 --- /dev/null +++ b/frontend/static/icons/2355.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2356.svg b/frontend/static/icons/2356.svg new file mode 100644 index 0000000..3a2ffb0 --- /dev/null +++ b/frontend/static/icons/2356.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2357.svg b/frontend/static/icons/2357.svg new file mode 100644 index 0000000..57cb5d7 --- /dev/null +++ b/frontend/static/icons/2357.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2358.svg b/frontend/static/icons/2358.svg new file mode 100644 index 0000000..55c509b --- /dev/null +++ b/frontend/static/icons/2358.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2359.svg b/frontend/static/icons/2359.svg new file mode 100644 index 0000000..b8ce8d4 --- /dev/null +++ b/frontend/static/icons/2359.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2360.svg b/frontend/static/icons/2360.svg new file mode 100644 index 0000000..91e4e3e --- /dev/null +++ b/frontend/static/icons/2360.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2361.svg b/frontend/static/icons/2361.svg new file mode 100644 index 0000000..f731066 --- /dev/null +++ b/frontend/static/icons/2361.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2362.svg b/frontend/static/icons/2362.svg new file mode 100644 index 0000000..e141b7f --- /dev/null +++ b/frontend/static/icons/2362.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2363.svg b/frontend/static/icons/2363.svg new file mode 100644 index 0000000..f69a147 --- /dev/null +++ b/frontend/static/icons/2363.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2364.svg b/frontend/static/icons/2364.svg new file mode 100644 index 0000000..dba246d --- /dev/null +++ b/frontend/static/icons/2364.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2365.svg b/frontend/static/icons/2365.svg new file mode 100644 index 0000000..b22301e --- /dev/null +++ b/frontend/static/icons/2365.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2366.svg b/frontend/static/icons/2366.svg new file mode 100644 index 0000000..07e3d4a --- /dev/null +++ b/frontend/static/icons/2366.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2367.svg b/frontend/static/icons/2367.svg new file mode 100644 index 0000000..79bbbff --- /dev/null +++ b/frontend/static/icons/2367.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2368.svg b/frontend/static/icons/2368.svg new file mode 100644 index 0000000..09a5345 --- /dev/null +++ b/frontend/static/icons/2368.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2369.svg b/frontend/static/icons/2369.svg new file mode 100644 index 0000000..fb5e7b5 --- /dev/null +++ b/frontend/static/icons/2369.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2370.svg b/frontend/static/icons/2370.svg new file mode 100644 index 0000000..6a6c308 --- /dev/null +++ b/frontend/static/icons/2370.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2371.svg b/frontend/static/icons/2371.svg new file mode 100644 index 0000000..b548764 --- /dev/null +++ b/frontend/static/icons/2371.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2372.svg b/frontend/static/icons/2372.svg new file mode 100644 index 0000000..1f2084f --- /dev/null +++ b/frontend/static/icons/2372.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2373.svg b/frontend/static/icons/2373.svg new file mode 100644 index 0000000..ca1c0b6 --- /dev/null +++ b/frontend/static/icons/2373.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2374.svg b/frontend/static/icons/2374.svg new file mode 100644 index 0000000..341d923 --- /dev/null +++ b/frontend/static/icons/2374.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2375.svg b/frontend/static/icons/2375.svg new file mode 100644 index 0000000..16e6d50 --- /dev/null +++ b/frontend/static/icons/2375.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2376.svg b/frontend/static/icons/2376.svg new file mode 100644 index 0000000..8b27291 --- /dev/null +++ b/frontend/static/icons/2376.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2377.svg b/frontend/static/icons/2377.svg new file mode 100644 index 0000000..fe09b42 --- /dev/null +++ b/frontend/static/icons/2377.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2378.svg b/frontend/static/icons/2378.svg new file mode 100644 index 0000000..db5587b --- /dev/null +++ b/frontend/static/icons/2378.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2379.svg b/frontend/static/icons/2379.svg new file mode 100644 index 0000000..ea7cfbe --- /dev/null +++ b/frontend/static/icons/2379.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2380.svg b/frontend/static/icons/2380.svg new file mode 100644 index 0000000..2638ba5 --- /dev/null +++ b/frontend/static/icons/2380.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2381.svg b/frontend/static/icons/2381.svg new file mode 100644 index 0000000..4043048 --- /dev/null +++ b/frontend/static/icons/2381.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2382.svg b/frontend/static/icons/2382.svg new file mode 100644 index 0000000..768764a --- /dev/null +++ b/frontend/static/icons/2382.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2383.svg b/frontend/static/icons/2383.svg new file mode 100644 index 0000000..b54aec8 --- /dev/null +++ b/frontend/static/icons/2383.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2384.svg b/frontend/static/icons/2384.svg new file mode 100644 index 0000000..0fe5430 --- /dev/null +++ b/frontend/static/icons/2384.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2385.svg b/frontend/static/icons/2385.svg new file mode 100644 index 0000000..845881c --- /dev/null +++ b/frontend/static/icons/2385.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2386.svg b/frontend/static/icons/2386.svg new file mode 100644 index 0000000..21583a6 --- /dev/null +++ b/frontend/static/icons/2386.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2387.svg b/frontend/static/icons/2387.svg new file mode 100644 index 0000000..0e1055a --- /dev/null +++ b/frontend/static/icons/2387.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2388.svg b/frontend/static/icons/2388.svg new file mode 100644 index 0000000..7424b52 --- /dev/null +++ b/frontend/static/icons/2388.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2389.svg b/frontend/static/icons/2389.svg new file mode 100644 index 0000000..ecf82d3 --- /dev/null +++ b/frontend/static/icons/2389.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2390.svg b/frontend/static/icons/2390.svg new file mode 100644 index 0000000..85194ed --- /dev/null +++ b/frontend/static/icons/2390.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2391.svg b/frontend/static/icons/2391.svg new file mode 100644 index 0000000..75cb711 --- /dev/null +++ b/frontend/static/icons/2391.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2392.svg b/frontend/static/icons/2392.svg new file mode 100644 index 0000000..b0f2e10 --- /dev/null +++ b/frontend/static/icons/2392.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2393.svg b/frontend/static/icons/2393.svg new file mode 100644 index 0000000..c5f8160 --- /dev/null +++ b/frontend/static/icons/2393.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2394.svg b/frontend/static/icons/2394.svg new file mode 100644 index 0000000..70b2078 --- /dev/null +++ b/frontend/static/icons/2394.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2395.svg b/frontend/static/icons/2395.svg new file mode 100644 index 0000000..4b0e634 --- /dev/null +++ b/frontend/static/icons/2395.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2396.svg b/frontend/static/icons/2396.svg new file mode 100644 index 0000000..246cfe9 --- /dev/null +++ b/frontend/static/icons/2396.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2397.svg b/frontend/static/icons/2397.svg new file mode 100644 index 0000000..5d11876 --- /dev/null +++ b/frontend/static/icons/2397.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2398.svg b/frontend/static/icons/2398.svg new file mode 100644 index 0000000..7b9888f --- /dev/null +++ b/frontend/static/icons/2398.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2399.svg b/frontend/static/icons/2399.svg new file mode 100644 index 0000000..3eb4246 --- /dev/null +++ b/frontend/static/icons/2399.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2400.svg b/frontend/static/icons/2400.svg new file mode 100644 index 0000000..5d98903 --- /dev/null +++ b/frontend/static/icons/2400.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2409.svg b/frontend/static/icons/2409.svg new file mode 100644 index 0000000..d4de888 --- /dev/null +++ b/frontend/static/icons/2409.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2411.svg b/frontend/static/icons/2411.svg new file mode 100644 index 0000000..c73b9be --- /dev/null +++ b/frontend/static/icons/2411.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2412.svg b/frontend/static/icons/2412.svg new file mode 100644 index 0000000..40405d9 --- /dev/null +++ b/frontend/static/icons/2412.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2413.svg b/frontend/static/icons/2413.svg new file mode 100644 index 0000000..55f8e73 --- /dev/null +++ b/frontend/static/icons/2413.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2414.svg b/frontend/static/icons/2414.svg new file mode 100644 index 0000000..dda86d6 --- /dev/null +++ b/frontend/static/icons/2414.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2415.svg b/frontend/static/icons/2415.svg new file mode 100644 index 0000000..ba163d7 --- /dev/null +++ b/frontend/static/icons/2415.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2416.svg b/frontend/static/icons/2416.svg new file mode 100644 index 0000000..7a34c89 --- /dev/null +++ b/frontend/static/icons/2416.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2417.svg b/frontend/static/icons/2417.svg new file mode 100644 index 0000000..22f8f22 --- /dev/null +++ b/frontend/static/icons/2417.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2418.svg b/frontend/static/icons/2418.svg new file mode 100644 index 0000000..f0b2ea1 --- /dev/null +++ b/frontend/static/icons/2418.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2419.svg b/frontend/static/icons/2419.svg new file mode 100644 index 0000000..64f7f3e --- /dev/null +++ b/frontend/static/icons/2419.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2420.svg b/frontend/static/icons/2420.svg new file mode 100644 index 0000000..9928b68 --- /dev/null +++ b/frontend/static/icons/2420.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2421.svg b/frontend/static/icons/2421.svg new file mode 100644 index 0000000..c36b000 --- /dev/null +++ b/frontend/static/icons/2421.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2422.svg b/frontend/static/icons/2422.svg new file mode 100644 index 0000000..a688a1f --- /dev/null +++ b/frontend/static/icons/2422.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2423.svg b/frontend/static/icons/2423.svg new file mode 100644 index 0000000..8fdd950 --- /dev/null +++ b/frontend/static/icons/2423.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2424.svg b/frontend/static/icons/2424.svg new file mode 100644 index 0000000..d618b99 --- /dev/null +++ b/frontend/static/icons/2424.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2425.svg b/frontend/static/icons/2425.svg new file mode 100644 index 0000000..b970315 --- /dev/null +++ b/frontend/static/icons/2425.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2426.svg b/frontend/static/icons/2426.svg new file mode 100644 index 0000000..1ea0713 --- /dev/null +++ b/frontend/static/icons/2426.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2501.svg b/frontend/static/icons/2501.svg new file mode 100644 index 0000000..edef801 --- /dev/null +++ b/frontend/static/icons/2501.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2502.svg b/frontend/static/icons/2502.svg new file mode 100644 index 0000000..166cbff --- /dev/null +++ b/frontend/static/icons/2502.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2521.svg b/frontend/static/icons/2521.svg new file mode 100644 index 0000000..6654758 --- /dev/null +++ b/frontend/static/icons/2521.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2522.svg b/frontend/static/icons/2522.svg new file mode 100644 index 0000000..5263d49 --- /dev/null +++ b/frontend/static/icons/2522.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2523.svg b/frontend/static/icons/2523.svg new file mode 100644 index 0000000..2b82ccc --- /dev/null +++ b/frontend/static/icons/2523.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2524.svg b/frontend/static/icons/2524.svg new file mode 100644 index 0000000..a02594d --- /dev/null +++ b/frontend/static/icons/2524.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2525.svg b/frontend/static/icons/2525.svg new file mode 100644 index 0000000..dac5f63 --- /dev/null +++ b/frontend/static/icons/2525.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2526.svg b/frontend/static/icons/2526.svg new file mode 100644 index 0000000..5608b4e --- /dev/null +++ b/frontend/static/icons/2526.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2527.svg b/frontend/static/icons/2527.svg new file mode 100644 index 0000000..244e04f --- /dev/null +++ b/frontend/static/icons/2527.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2528.svg b/frontend/static/icons/2528.svg new file mode 100644 index 0000000..3ba2067 --- /dev/null +++ b/frontend/static/icons/2528.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2529.svg b/frontend/static/icons/2529.svg new file mode 100644 index 0000000..c76a339 --- /dev/null +++ b/frontend/static/icons/2529.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2530.svg b/frontend/static/icons/2530.svg new file mode 100644 index 0000000..8e76e76 --- /dev/null +++ b/frontend/static/icons/2530.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2531.svg b/frontend/static/icons/2531.svg new file mode 100644 index 0000000..f2aa91a --- /dev/null +++ b/frontend/static/icons/2531.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/2532.svg b/frontend/static/icons/2532.svg new file mode 100644 index 0000000..1039ed1 --- /dev/null +++ b/frontend/static/icons/2532.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2550.svg b/frontend/static/icons/2550.svg new file mode 100644 index 0000000..23c3a03 --- /dev/null +++ b/frontend/static/icons/2550.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2551.svg b/frontend/static/icons/2551.svg new file mode 100644 index 0000000..bf180c0 --- /dev/null +++ b/frontend/static/icons/2551.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/2552.svg b/frontend/static/icons/2552.svg new file mode 100644 index 0000000..deba591 --- /dev/null +++ b/frontend/static/icons/2552.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2553.svg b/frontend/static/icons/2553.svg new file mode 100644 index 0000000..d483ed4 --- /dev/null +++ b/frontend/static/icons/2553.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/2554.svg b/frontend/static/icons/2554.svg new file mode 100644 index 0000000..47da238 --- /dev/null +++ b/frontend/static/icons/2554.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/300-fill.svg b/frontend/static/icons/300-fill.svg new file mode 100644 index 0000000..1ee4a8b --- /dev/null +++ b/frontend/static/icons/300-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/300.svg b/frontend/static/icons/300.svg new file mode 100644 index 0000000..4a70d96 --- /dev/null +++ b/frontend/static/icons/300.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/301-fill.svg b/frontend/static/icons/301-fill.svg new file mode 100644 index 0000000..b0b043f --- /dev/null +++ b/frontend/static/icons/301-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/301.svg b/frontend/static/icons/301.svg new file mode 100644 index 0000000..50c116f --- /dev/null +++ b/frontend/static/icons/301.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/302-fill.svg b/frontend/static/icons/302-fill.svg new file mode 100644 index 0000000..2a2f2e5 --- /dev/null +++ b/frontend/static/icons/302-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/302.svg b/frontend/static/icons/302.svg new file mode 100644 index 0000000..51c6536 --- /dev/null +++ b/frontend/static/icons/302.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/303-fill.svg b/frontend/static/icons/303-fill.svg new file mode 100644 index 0000000..d92a701 --- /dev/null +++ b/frontend/static/icons/303-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/303.svg b/frontend/static/icons/303.svg new file mode 100644 index 0000000..64b5cf9 --- /dev/null +++ b/frontend/static/icons/303.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/304-fill.svg b/frontend/static/icons/304-fill.svg new file mode 100644 index 0000000..bb4a8b4 --- /dev/null +++ b/frontend/static/icons/304-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/304.svg b/frontend/static/icons/304.svg new file mode 100644 index 0000000..167ed4f --- /dev/null +++ b/frontend/static/icons/304.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/305-fill.svg b/frontend/static/icons/305-fill.svg new file mode 100644 index 0000000..788d595 --- /dev/null +++ b/frontend/static/icons/305-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/305.svg b/frontend/static/icons/305.svg new file mode 100644 index 0000000..5190dcd --- /dev/null +++ b/frontend/static/icons/305.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/306-fill.svg b/frontend/static/icons/306-fill.svg new file mode 100644 index 0000000..9eb78ac --- /dev/null +++ b/frontend/static/icons/306-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/306.svg b/frontend/static/icons/306.svg new file mode 100644 index 0000000..e2fedd5 --- /dev/null +++ b/frontend/static/icons/306.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/307-fill.svg b/frontend/static/icons/307-fill.svg new file mode 100644 index 0000000..a672532 --- /dev/null +++ b/frontend/static/icons/307-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/307.svg b/frontend/static/icons/307.svg new file mode 100644 index 0000000..73e3025 --- /dev/null +++ b/frontend/static/icons/307.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/308-fill.svg b/frontend/static/icons/308-fill.svg new file mode 100644 index 0000000..22aba0f --- /dev/null +++ b/frontend/static/icons/308-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/308.svg b/frontend/static/icons/308.svg new file mode 100644 index 0000000..950f6e4 --- /dev/null +++ b/frontend/static/icons/308.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/309-fill.svg b/frontend/static/icons/309-fill.svg new file mode 100644 index 0000000..dd03e77 --- /dev/null +++ b/frontend/static/icons/309-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/309.svg b/frontend/static/icons/309.svg new file mode 100644 index 0000000..5268dd1 --- /dev/null +++ b/frontend/static/icons/309.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/310-fill.svg b/frontend/static/icons/310-fill.svg new file mode 100644 index 0000000..6d438c1 --- /dev/null +++ b/frontend/static/icons/310-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/310.svg b/frontend/static/icons/310.svg new file mode 100644 index 0000000..b1a16f4 --- /dev/null +++ b/frontend/static/icons/310.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/311-fill.svg b/frontend/static/icons/311-fill.svg new file mode 100644 index 0000000..c1f4cd2 --- /dev/null +++ b/frontend/static/icons/311-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/311.svg b/frontend/static/icons/311.svg new file mode 100644 index 0000000..211cf25 --- /dev/null +++ b/frontend/static/icons/311.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/312-fill.svg b/frontend/static/icons/312-fill.svg new file mode 100644 index 0000000..8f12212 --- /dev/null +++ b/frontend/static/icons/312-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/312.svg b/frontend/static/icons/312.svg new file mode 100644 index 0000000..d98270a --- /dev/null +++ b/frontend/static/icons/312.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/313-fill.svg b/frontend/static/icons/313-fill.svg new file mode 100644 index 0000000..b2a5830 --- /dev/null +++ b/frontend/static/icons/313-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/313.svg b/frontend/static/icons/313.svg new file mode 100644 index 0000000..c32f953 --- /dev/null +++ b/frontend/static/icons/313.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/314-fill.svg b/frontend/static/icons/314-fill.svg new file mode 100644 index 0000000..2c2ea76 --- /dev/null +++ b/frontend/static/icons/314-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/314.svg b/frontend/static/icons/314.svg new file mode 100644 index 0000000..a384dee --- /dev/null +++ b/frontend/static/icons/314.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/315-fill.svg b/frontend/static/icons/315-fill.svg new file mode 100644 index 0000000..e4bc831 --- /dev/null +++ b/frontend/static/icons/315-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/315.svg b/frontend/static/icons/315.svg new file mode 100644 index 0000000..68fba7d --- /dev/null +++ b/frontend/static/icons/315.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/316-fill.svg b/frontend/static/icons/316-fill.svg new file mode 100644 index 0000000..74a63b7 --- /dev/null +++ b/frontend/static/icons/316-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/316.svg b/frontend/static/icons/316.svg new file mode 100644 index 0000000..32119ec --- /dev/null +++ b/frontend/static/icons/316.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/317-fill.svg b/frontend/static/icons/317-fill.svg new file mode 100644 index 0000000..a7db999 --- /dev/null +++ b/frontend/static/icons/317-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/317.svg b/frontend/static/icons/317.svg new file mode 100644 index 0000000..c1b1d9e --- /dev/null +++ b/frontend/static/icons/317.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/318-fill.svg b/frontend/static/icons/318-fill.svg new file mode 100644 index 0000000..1198ceb --- /dev/null +++ b/frontend/static/icons/318-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/318.svg b/frontend/static/icons/318.svg new file mode 100644 index 0000000..8349d00 --- /dev/null +++ b/frontend/static/icons/318.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/350-fill.svg b/frontend/static/icons/350-fill.svg new file mode 100644 index 0000000..bc33bc1 --- /dev/null +++ b/frontend/static/icons/350-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/350.svg b/frontend/static/icons/350.svg new file mode 100644 index 0000000..3639e45 --- /dev/null +++ b/frontend/static/icons/350.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/351-fill.svg b/frontend/static/icons/351-fill.svg new file mode 100644 index 0000000..f497a28 --- /dev/null +++ b/frontend/static/icons/351-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/351.svg b/frontend/static/icons/351.svg new file mode 100644 index 0000000..35811eb --- /dev/null +++ b/frontend/static/icons/351.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/399-fill.svg b/frontend/static/icons/399-fill.svg new file mode 100644 index 0000000..6165118 --- /dev/null +++ b/frontend/static/icons/399-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/399.svg b/frontend/static/icons/399.svg new file mode 100644 index 0000000..01946d3 --- /dev/null +++ b/frontend/static/icons/399.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/400-fill.svg b/frontend/static/icons/400-fill.svg new file mode 100644 index 0000000..8b2855e --- /dev/null +++ b/frontend/static/icons/400-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/400.svg b/frontend/static/icons/400.svg new file mode 100644 index 0000000..7e726e5 --- /dev/null +++ b/frontend/static/icons/400.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/401-fill.svg b/frontend/static/icons/401-fill.svg new file mode 100644 index 0000000..9fccde0 --- /dev/null +++ b/frontend/static/icons/401-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/401.svg b/frontend/static/icons/401.svg new file mode 100644 index 0000000..8fe4027 --- /dev/null +++ b/frontend/static/icons/401.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/402-fill.svg b/frontend/static/icons/402-fill.svg new file mode 100644 index 0000000..c3446d7 --- /dev/null +++ b/frontend/static/icons/402-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/402.svg b/frontend/static/icons/402.svg new file mode 100644 index 0000000..9ae768e --- /dev/null +++ b/frontend/static/icons/402.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/403-fill.svg b/frontend/static/icons/403-fill.svg new file mode 100644 index 0000000..e801c0b --- /dev/null +++ b/frontend/static/icons/403-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/403.svg b/frontend/static/icons/403.svg new file mode 100644 index 0000000..4707249 --- /dev/null +++ b/frontend/static/icons/403.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/404-fill.svg b/frontend/static/icons/404-fill.svg new file mode 100644 index 0000000..fd74359 --- /dev/null +++ b/frontend/static/icons/404-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/404.svg b/frontend/static/icons/404.svg new file mode 100644 index 0000000..38e6570 --- /dev/null +++ b/frontend/static/icons/404.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/405-fill.svg b/frontend/static/icons/405-fill.svg new file mode 100644 index 0000000..e9a5ef1 --- /dev/null +++ b/frontend/static/icons/405-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/405.svg b/frontend/static/icons/405.svg new file mode 100644 index 0000000..5b9f849 --- /dev/null +++ b/frontend/static/icons/405.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/406-fill.svg b/frontend/static/icons/406-fill.svg new file mode 100644 index 0000000..d995ccb --- /dev/null +++ b/frontend/static/icons/406-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/406.svg b/frontend/static/icons/406.svg new file mode 100644 index 0000000..4b83f04 --- /dev/null +++ b/frontend/static/icons/406.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/407-fill.svg b/frontend/static/icons/407-fill.svg new file mode 100644 index 0000000..7e839fb --- /dev/null +++ b/frontend/static/icons/407-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/407.svg b/frontend/static/icons/407.svg new file mode 100644 index 0000000..8a85a5f --- /dev/null +++ b/frontend/static/icons/407.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/408-fill.svg b/frontend/static/icons/408-fill.svg new file mode 100644 index 0000000..ef30073 --- /dev/null +++ b/frontend/static/icons/408-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/408.svg b/frontend/static/icons/408.svg new file mode 100644 index 0000000..e40c728 --- /dev/null +++ b/frontend/static/icons/408.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/409-fill.svg b/frontend/static/icons/409-fill.svg new file mode 100644 index 0000000..7810ae4 --- /dev/null +++ b/frontend/static/icons/409-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/409.svg b/frontend/static/icons/409.svg new file mode 100644 index 0000000..f03f720 --- /dev/null +++ b/frontend/static/icons/409.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/410-fill.svg b/frontend/static/icons/410-fill.svg new file mode 100644 index 0000000..408854e --- /dev/null +++ b/frontend/static/icons/410-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/410.svg b/frontend/static/icons/410.svg new file mode 100644 index 0000000..a0d3f10 --- /dev/null +++ b/frontend/static/icons/410.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/icons/456-fill.svg b/frontend/static/icons/456-fill.svg new file mode 100644 index 0000000..edd6df4 --- /dev/null +++ b/frontend/static/icons/456-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/456.svg b/frontend/static/icons/456.svg new file mode 100644 index 0000000..c85ae19 --- /dev/null +++ b/frontend/static/icons/456.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/457-fill.svg b/frontend/static/icons/457-fill.svg new file mode 100644 index 0000000..946990e --- /dev/null +++ b/frontend/static/icons/457-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/457.svg b/frontend/static/icons/457.svg new file mode 100644 index 0000000..b5fdf64 --- /dev/null +++ b/frontend/static/icons/457.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/499-fill.svg b/frontend/static/icons/499-fill.svg new file mode 100644 index 0000000..412acfe --- /dev/null +++ b/frontend/static/icons/499-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/499.svg b/frontend/static/icons/499.svg new file mode 100644 index 0000000..c6c1111 --- /dev/null +++ b/frontend/static/icons/499.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/500-fill.svg b/frontend/static/icons/500-fill.svg new file mode 100644 index 0000000..7f23e70 --- /dev/null +++ b/frontend/static/icons/500-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/500.svg b/frontend/static/icons/500.svg new file mode 100644 index 0000000..0fed18c --- /dev/null +++ b/frontend/static/icons/500.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/501-fill.svg b/frontend/static/icons/501-fill.svg new file mode 100644 index 0000000..4293cb4 --- /dev/null +++ b/frontend/static/icons/501-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/501.svg b/frontend/static/icons/501.svg new file mode 100644 index 0000000..4ff02b6 --- /dev/null +++ b/frontend/static/icons/501.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/502-fill.svg b/frontend/static/icons/502-fill.svg new file mode 100644 index 0000000..c77fc0a --- /dev/null +++ b/frontend/static/icons/502-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/502.svg b/frontend/static/icons/502.svg new file mode 100644 index 0000000..f0f0afb --- /dev/null +++ b/frontend/static/icons/502.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/503-fill.svg b/frontend/static/icons/503-fill.svg new file mode 100644 index 0000000..46775e3 --- /dev/null +++ b/frontend/static/icons/503-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/503.svg b/frontend/static/icons/503.svg new file mode 100644 index 0000000..c866876 --- /dev/null +++ b/frontend/static/icons/503.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/504-fill.svg b/frontend/static/icons/504-fill.svg new file mode 100644 index 0000000..51b1f72 --- /dev/null +++ b/frontend/static/icons/504-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/504.svg b/frontend/static/icons/504.svg new file mode 100644 index 0000000..d65b694 --- /dev/null +++ b/frontend/static/icons/504.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/507-fill.svg b/frontend/static/icons/507-fill.svg new file mode 100644 index 0000000..56d21bf --- /dev/null +++ b/frontend/static/icons/507-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/507.svg b/frontend/static/icons/507.svg new file mode 100644 index 0000000..5c566db --- /dev/null +++ b/frontend/static/icons/507.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/508-fill.svg b/frontend/static/icons/508-fill.svg new file mode 100644 index 0000000..40e1d12 --- /dev/null +++ b/frontend/static/icons/508-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/508.svg b/frontend/static/icons/508.svg new file mode 100644 index 0000000..9794844 --- /dev/null +++ b/frontend/static/icons/508.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/509-fill.svg b/frontend/static/icons/509-fill.svg new file mode 100644 index 0000000..f23ab92 --- /dev/null +++ b/frontend/static/icons/509-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/509.svg b/frontend/static/icons/509.svg new file mode 100644 index 0000000..15c4552 --- /dev/null +++ b/frontend/static/icons/509.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/510-fill.svg b/frontend/static/icons/510-fill.svg new file mode 100644 index 0000000..98f8fe4 --- /dev/null +++ b/frontend/static/icons/510-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/510.svg b/frontend/static/icons/510.svg new file mode 100644 index 0000000..3eb2d13 --- /dev/null +++ b/frontend/static/icons/510.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/511-fill.svg b/frontend/static/icons/511-fill.svg new file mode 100644 index 0000000..ada9420 --- /dev/null +++ b/frontend/static/icons/511-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/511.svg b/frontend/static/icons/511.svg new file mode 100644 index 0000000..44c91b3 --- /dev/null +++ b/frontend/static/icons/511.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/512-fill.svg b/frontend/static/icons/512-fill.svg new file mode 100644 index 0000000..aaa7215 --- /dev/null +++ b/frontend/static/icons/512-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/512.svg b/frontend/static/icons/512.svg new file mode 100644 index 0000000..af48401 --- /dev/null +++ b/frontend/static/icons/512.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/513-fill.svg b/frontend/static/icons/513-fill.svg new file mode 100644 index 0000000..95646d8 --- /dev/null +++ b/frontend/static/icons/513-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/513.svg b/frontend/static/icons/513.svg new file mode 100644 index 0000000..30e9382 --- /dev/null +++ b/frontend/static/icons/513.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/514-fill.svg b/frontend/static/icons/514-fill.svg new file mode 100644 index 0000000..47af727 --- /dev/null +++ b/frontend/static/icons/514-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/514.svg b/frontend/static/icons/514.svg new file mode 100644 index 0000000..9167a40 --- /dev/null +++ b/frontend/static/icons/514.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/515-fill.svg b/frontend/static/icons/515-fill.svg new file mode 100644 index 0000000..6ff6c6a --- /dev/null +++ b/frontend/static/icons/515-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/515.svg b/frontend/static/icons/515.svg new file mode 100644 index 0000000..36f0cc3 --- /dev/null +++ b/frontend/static/icons/515.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/800.svg b/frontend/static/icons/800.svg new file mode 100644 index 0000000..7a5f34d --- /dev/null +++ b/frontend/static/icons/800.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/801.svg b/frontend/static/icons/801.svg new file mode 100644 index 0000000..87ad4b6 --- /dev/null +++ b/frontend/static/icons/801.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/802.svg b/frontend/static/icons/802.svg new file mode 100644 index 0000000..2f02424 --- /dev/null +++ b/frontend/static/icons/802.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/803.svg b/frontend/static/icons/803.svg new file mode 100644 index 0000000..c713455 --- /dev/null +++ b/frontend/static/icons/803.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/804.svg b/frontend/static/icons/804.svg new file mode 100644 index 0000000..baab6b4 --- /dev/null +++ b/frontend/static/icons/804.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/805.svg b/frontend/static/icons/805.svg new file mode 100644 index 0000000..7601207 --- /dev/null +++ b/frontend/static/icons/805.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/806.svg b/frontend/static/icons/806.svg new file mode 100644 index 0000000..a0b70b6 --- /dev/null +++ b/frontend/static/icons/806.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/807.svg b/frontend/static/icons/807.svg new file mode 100644 index 0000000..3853402 --- /dev/null +++ b/frontend/static/icons/807.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/900-fill.svg b/frontend/static/icons/900-fill.svg new file mode 100644 index 0000000..193c0b8 --- /dev/null +++ b/frontend/static/icons/900-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/900.svg b/frontend/static/icons/900.svg new file mode 100644 index 0000000..4991e01 --- /dev/null +++ b/frontend/static/icons/900.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/901-fill.svg b/frontend/static/icons/901-fill.svg new file mode 100644 index 0000000..be28bd8 --- /dev/null +++ b/frontend/static/icons/901-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/901.svg b/frontend/static/icons/901.svg new file mode 100644 index 0000000..7f1fe14 --- /dev/null +++ b/frontend/static/icons/901.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/999-fill.svg b/frontend/static/icons/999-fill.svg new file mode 100644 index 0000000..91d9d4f --- /dev/null +++ b/frontend/static/icons/999-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/999.svg b/frontend/static/icons/999.svg new file mode 100644 index 0000000..865cc87 --- /dev/null +++ b/frontend/static/icons/999.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/9999.svg b/frontend/static/icons/9999.svg new file mode 100644 index 0000000..51a7425 --- /dev/null +++ b/frontend/static/icons/9999.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/icons/qweather-fill.svg b/frontend/static/icons/qweather-fill.svg new file mode 100644 index 0000000..819aa6a --- /dev/null +++ b/frontend/static/icons/qweather-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/static/icons/qweather.svg b/frontend/static/icons/qweather.svg new file mode 100644 index 0000000..6a7cd02 --- /dev/null +++ b/frontend/static/icons/qweather.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/img/kitty.png b/frontend/static/img/kitty.png new file mode 100644 index 0000000000000000000000000000000000000000..9630ddf10b9fbd10487efa7cc95ebe38048fdb01 GIT binary patch literal 63900 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 Yc~SC1qR08=68>vm*uv0b!AW`lAE-9mVE_OC literal 0 HcmV?d00001 diff --git a/frontend/static/js/control.js b/frontend/static/js/control.js new file mode 100644 index 0000000..c5d5471 --- /dev/null +++ b/frontend/static/js/control.js @@ -0,0 +1,1392 @@ +/* Control page - vanilla JS, no frameworks */ +(function () { + 'use strict'; + + var $ = function (id) { return document.getElementById(id); }; + + // ─── Tabs ───────────────────────────────── + document.querySelectorAll('.tab').forEach(function (btn) { + btn.addEventListener('click', function () { + document.querySelectorAll('.tab').forEach(function (b) { b.classList.remove('active'); }); + document.querySelectorAll('.tab-content').forEach(function (c) { c.classList.remove('active'); }); + btn.classList.add('active'); + var target = $('tab-' + btn.dataset.tab); + if (target) target.classList.add('active'); + }); + }); + + // ─── Helpers ────────────────────────────── + function api(method, url, data, callback) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + if (data && !(data instanceof FormData)) { + xhr.setRequestHeader('Content-Type', 'application/json'); + } + xhr.onload = function () { + if (xhr.status === 401) { + var res = JSON.parse(xhr.responseText || '{}'); + if (res.login_required) { + location.href = '/login?view=control&next=' + encodeURIComponent(location.pathname + location.search); + return; + } + } + if (callback) callback(JSON.parse(xhr.responseText || '{}')); + }; + if (data instanceof FormData) { + xhr.send(data); + } else if (data) { + xhr.send(JSON.stringify(data)); + } else { + xhr.send(); + } + } + + // HTML escape for untrusted data — prevents XSS in innerHTML + function _esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // Re-authentication guard for sensitive operations (user management, MFA changes) + // Passkey first; TOTP is the fallback when no Passkey is configured. + function _reauthThen(callback) { + api('GET', '/api/auth/mfa/status', null, function (s) { + var hasPasskey = !!(s && s.passkey_enabled); + var hasTotp = !!(s && s.totp_enabled); + if (hasPasskey) { + _passkeyReauth(callback, function () { + // Passkey 失败/取消 → 有 TOTP 则回退 TOTP + if (hasTotp) _totpReauth(callback); + }); + } else if (hasTotp) { + _totpReauth(callback); + } else { + // temp_pending sessions pass reauth without a code + api('POST', '/api/auth/reauth', { code: '' }, function (res) { + if (res.ok) { callback(); } else { toast('验证失败,请重试'); } + }); + } + }); + } + + function _totpReauth(callback) { + showPrompt('身份验证', '6 位 TOTP 验证码', '', function (code) { + if (code === null || code === '') return; // 取消 + api('POST', '/api/auth/reauth', { code: code }, function (res) { + if (res.ok) { callback(); } else { toast('验证失败,请重试'); } + }); + }, { okText: '验证', inputType: 'text', inputmode: 'numeric', maxlength: 6 }); + } + + function _passkeyReauth(callback, onFail) { + onFail = onFail || function () {}; + api('POST', '/api/auth/reauth/webauthn/begin', null, function (options) { + if (options.error) { toast(options.error); onFail(); return; } + // Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController + PasskeyBridge.assertion(options) + .then(function (credential) { + api('POST', '/api/auth/reauth/webauthn/complete', { credential: credential }, function (res) { + if (res.ok) { callback(); } else { toast('验证失败,请重试'); onFail(); } + }); + }) + .catch(function (err) { + toast('Passkey 验证已取消'); + onFail(); + }); + }); + } + + function toast(msg) { + var el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(function () { el.remove(); }, 2500); + } + + // ─── Calendar Groups ────────────────────── + function loadGroups() { + api('GET', '/api/calendar/groups', null, function (groups) { + var html = ''; + groups.forEach(function (g) { + html += '
' + + '
' + + '
' + + '
' + + '
' + g.name + + ' ' + (g.enabled ? '显示' : '隐藏') + '
' + + '
' + g.sources.length + ' 个数据源
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + // Sources + g.sources.forEach(function (s) { + html += '
' + + '' + (s.type === 'upload' ? '📄' : '🔗') + ' ' + s.name + '' + + '' + + (s.type === 'subscription' ? ' ' : '') + + '' + + '
'; + }); + }); + $('group-list').innerHTML = html || '
暂无分组
'; + bindGroupActions(); + + // Update selects + [['upload-group','subscribe-group','reminder-group']].forEach(function () { + var selHtml = ''; + groups.forEach(function (g) { + selHtml += ''; + }); + ['upload-group','subscribe-group','reminder-group'].forEach(function (id) { + var sel = $(id); + if (sel) sel.innerHTML = selHtml; + }); + }); + }); + } + + function bindGroupActions() { + document.querySelectorAll('#group-list [data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = btn.dataset.action; + var id = btn.dataset.id; + if (action === 'toggle') { + api('PUT', '/api/calendar/groups/' + id, { enabled: btn.dataset.enabled === '1' }, function () { + loadGroups(); + }); + } else if (action === 'delete') { + showConfirm('删除分组', '确认删除此分组及其所有数据?', function () { + api('DELETE', '/api/calendar/groups/' + id, null, function () { loadGroups(); }); + }, { okText: '删除' }); + } else if (action === 'sync') { + api('POST', '/api/calendar/sources/' + id + '/sync', null, function () { + toast('同步完成'); + loadGroups(); + }); + } else if (action === 'delete-source') { + showConfirm('删除数据源', '删除此数据源?', function () { + api('DELETE', '/api/calendar/sources/' + id, null, function () { loadGroups(); }); + }, { okText: '删除' }); + } else if (action === 'view-events') { + var eventsDiv = $('group-events-' + id); + if (eventsDiv) { + eventsDiv.style.display = eventsDiv.style.display === 'none' ? 'block' : 'none'; + } else { + loadInlineGroupEvents(id); + } + } + }); + }); + } + + $('group-form').addEventListener('submit', function (e) { + e.preventDefault(); + api('POST', '/api/calendar/groups', { + name: $('group-name').value, + color: $('group-color').value, + }, function () { + $('group-name').value = ''; + loadGroups(); + }); + }); + + // ─── ICS Upload ─────────────────────────── + $('upload-form').addEventListener('submit', function (e) { + e.preventDefault(); + var fd = new FormData(); + fd.append('group_id', $('upload-group').value); + fd.append('name', $('upload-name').value); + fd.append('file', $('upload-file').files[0]); + api('POST', '/api/calendar/sources/upload', fd, function () { + $('upload-name').value = ''; + $('upload-file').value = ''; + toast('上传成功'); + loadGroups(); + }); + }); + + // ─── ICS Subscribe ──────────────────────── + $('subscribe-form').addEventListener('submit', function (e) { + e.preventDefault(); + api('POST', '/api/calendar/sources/subscribe', { + group_id: parseInt($('subscribe-group').value), + name: $('subscribe-name').value, + url: $('subscribe-url').value, + }, function () { + $('subscribe-name').value = ''; + $('subscribe-url').value = ''; + toast('订阅成功'); + loadGroups(); + }); + }); + + // ─── Reminders ──────────────────────────── + $('reminder-form').addEventListener('submit', function (e) { + e.preventDefault(); + api('PUT', '/api/calendar/groups/' + $('reminder-group').value + '/reminder', { + minutes_before: parseInt($('reminder-minutes').value), + enabled: $('reminder-enabled').checked, + }, function () { + toast('提醒设置已保存'); + }); + }); + + // ─── Weather (QWeather) ──────────────── + function loadWeatherConfig() { + api('GET', '/api/weather/config', null, function (cfg) { + $('weather-city').value = cfg.city || ''; + $('weather-lat').value = cfg.lat || ''; + $('weather-lon').value = cfg.lon || ''; + $('weather-units').value = cfg.units || 'metric'; + $('weather-kid').value = cfg.credential_id || ''; + $('weather-sub').value = cfg.project_id || ''; + $('weather-host').value = cfg.qweather_host || ''; + $('weather-apikey').value = cfg.api_key === '***' ? '' : (cfg.api_key || ''); + if (cfg.jwt_public_key) { + $('weather-public-key').value = cfg.jwt_public_key; + } + _refreshWeatherPreview(); + }); + } + + // ─── City Search ────────────────────────── + $('city-search-btn').addEventListener('click', function () { + var q = $('weather-city-search').value.trim(); + if (!q) return; + api('GET', '/api/weather/city_lookup?q=' + encodeURIComponent(q), null, function (results) { + var container = $('city-results'); + if (!results || results.length === 0) { + container.innerHTML = '
未找到匹配城市
'; + return; + } + var html = ''; + results.forEach(function (loc) { + html += '
' + + '' + loc.name + ' ' + (loc.adm1 || '') + ' ' + (loc.country || '') + '' + + '' + loc.lat + ', ' + loc.lon + '' + + '
'; + }); + container.innerHTML = html; + // Click to select + container.querySelectorAll('.city-result').forEach(function (el) { + el.addEventListener('click', function () { + $('weather-city').value = el.dataset.name; + $('weather-lat').value = el.dataset.lat; + $('weather-lon').value = el.dataset.lon; + container.innerHTML = '
已选择: ' + el.dataset.name + '
'; + }); + }); + }); + }); + + // ─── Key Generation ─────────────────────── + $('generate-keys-btn').addEventListener('click', function () { + var statusEl = $('key-gen-status'); + statusEl.textContent = '生成中...'; + api('POST', '/api/weather/generate_keys', null, function (result) { + if (result.public_key) { + $('weather-public-key').value = result.public_key; + statusEl.textContent = '✅ 密钥对已生成,请将公钥复制到和风控制台'; + } else { + statusEl.textContent = '❌ 生成失败'; + } + }); + }); + + // ─── Save Config ────────────────────────── + $('weather-form').addEventListener('submit', function (e) { + e.preventDefault(); + var statusEl = $('weather-save-status'); + statusEl.textContent = '保存中...'; + var data = { + city: $('weather-city').value, + lat: parseFloat($('weather-lat').value) || undefined, + lon: parseFloat($('weather-lon').value) || undefined, + units: $('weather-units').value, + api_key: $('weather-apikey').value || undefined, + credential_id: $('weather-kid').value || undefined, + project_id: $('weather-sub').value || undefined, + qweather_host: $('weather-host').value || undefined, + }; + api('PUT', '/api/weather/config', data, function () { + statusEl.textContent = '✅ 已保存'; + _refreshWeatherPreview(); + }); + }); + + // ─── Refresh ────────────────────────────── + function _refreshWeatherPreview() { + api('GET', '/api/weather/current', null, function (data) { + var el = $('weather-preview'); + if (data.error) { + el.innerHTML = '' + data.error + ''; + } else { + el.innerHTML = '' + (data.city || '') + ' ' + + (data.temp !== undefined ? data.temp + '°C' : '') + + (data.description ? ' ' + data.description : '') + + (data.feels_like ? ' 体感' + data.feels_like + '°C' : '') + + (data.wind_dir ? ' ' + data.wind_dir : '') + + (data.wind_speed ? ' ' + data.wind_speed + 'km/h' : '') + + (data.humidity ? ' 湿度' + data.humidity + '%' : '') + + (data.precip && data.precip != '0' ? ' 降水' + data.precip + 'mm' : '') + + (data.visibility ? ' 能见度' + data.visibility + 'km' : '') + + (data.dew ? ' 露点' + data.dew + '°C' : '') + + (data.updated_at ? '
更新: ' + data.updated_at + '' : ''); + } + }); + } + + $('weather-refresh').addEventListener('click', function () { + api('POST', '/api/weather/refresh', null, function (data) { + if (data.error) { + toast('刷新失败: ' + data.error); + } else { + toast(data.city + ': ' + data.temp + '°C ' + data.description); + _refreshWeatherPreview(); + } + }); + }); + + // ─── Alarms ─────────────────────────────── + function loadAlarms() { + api('GET', '/api/alarms', null, function (alarms) { + var dayNames = ['', '一', '二', '三', '四', '五', '六', '日']; + var html = ''; + alarms.forEach(function (a) { + var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' '); + html += '
' + + '
' + a.time + ' ' + + (a.label || '') + + ' ' + days + '' + + ' ' + (a.enabled ? '开' : '关') + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }); + $('alarm-list').innerHTML = html || '
暂无闹钟
'; + document.querySelectorAll('[data-action="toggle-alarm"]').forEach(function (btn) { + btn.addEventListener('click', function () { + api('PUT', '/api/alarms/' + btn.dataset.id, { enabled: btn.dataset.enabled === '1' }, function () { + loadAlarms(); + }); + }); + }); + document.querySelectorAll('[data-action="delete-alarm"]').forEach(function (btn) { + btn.addEventListener('click', function () { + api('DELETE', '/api/alarms/' + btn.dataset.id, null, function () { loadAlarms(); }); + }); + }); + }); + } + + // Once-only toggle — hide day picker + var alarmOnceCb = $('alarm-once'); + if (alarmOnceCb) { + alarmOnceCb.addEventListener('change', function () { + var dp = $('alarm-days-picker'); + if (dp) dp.style.display = alarmOnceCb.checked ? 'none' : ''; + }); + } + + $('alarm-form').addEventListener('submit', function (e) { + e.preventDefault(); + var days = []; + document.querySelectorAll('#alarm-days-picker input:checked').forEach(function (cb) { + days.push(cb.value); + }); + api('POST', '/api/alarms', { + label: $('alarm-label').value, + time: $('alarm-time').value, + days: days.join(','), + sound: $('alarm-sound').value, + once: $('alarm-once').checked, + }, function () { + $('alarm-label').value = ''; + $('alarm-once').checked = false; + var dp = $('alarm-days-picker'); + if (dp) dp.style.display = ''; + loadAlarms(); + }); + }); + + // ─── Ringtone ──────────────────────────── + $('ringtone-form').addEventListener('submit', function (e) { + e.preventDefault(); + var fd = new FormData(); + fd.append('file', $('ringtone-file').files[0]); + api('POST', '/api/alarms/ringtone', fd, function (res) { + if (res.filename) { + toast('铃声上传成功: ' + res.filename); + $('ringtone-file').value = ''; + // Add to sound selector + var sel = $('alarm-sound'); + var opt = document.createElement('option'); + opt.value = res.filename; + opt.textContent = res.filename; + sel.appendChild(opt); + // Show in ringtone list + var list = $('ringtone-list'); + list.innerHTML += '
' + res.filename + '
'; + } + }); + }); + + // ─── Display Mode ───────────────────────── + var htmlTemplates = { 0: '', 1: '', 2: '', 3: '' }; + var currentHtmlIndex = 0; + + function loadDisplayMode() { + api('GET', '/api/display/mode', null, function (cfg) { + $('display-mode').value = cfg.mode || 'default'; + htmlTemplates[0] = cfg.custom_html || ''; + htmlTemplates[1] = cfg.custom_html_1 || ''; + htmlTemplates[2] = cfg.custom_html_2 || ''; + htmlTemplates[3] = cfg.custom_html_3 || ''; + updateHtmlEditor(); + }); + } + + function updateHtmlEditor() { + $('custom-html-editor').value = htmlTemplates[currentHtmlIndex]; + $('html-editor-label').textContent = 'HTML ' + currentHtmlIndex; + document.querySelectorAll('.html-tab-btn').forEach(function (btn) { + btn.classList.toggle('active', parseInt(btn.dataset.idx, 10) === currentHtmlIndex); + }); + } + + document.querySelectorAll('.html-tab-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + // Auto-save current before switching + htmlTemplates[currentHtmlIndex] = $('custom-html-editor').value; + var idx = parseInt(btn.dataset.idx, 10); + currentHtmlIndex = idx; + updateHtmlEditor(); + // Auto-save to server + var url = idx === 0 ? '/api/display/custom_html' : '/api/display/custom_html/' + idx; + api('PUT', url, { html: htmlTemplates[idx] }, function () {}); + }); + }); + + $('mode-form').addEventListener('submit', function (e) { + e.preventDefault(); + var mode = $('display-mode').value; + var msg = $('mode-msg'); + api('PUT', '/api/display/mode', { mode: mode }, function (res) { + if (res.ok) { + msg.textContent = '模式已切换为: ' + (mode === 'custom' ? '自定义 HTML' : '默认页面'); + msg.style.color = '#2e7d32'; + } else { + msg.textContent = res.error || '切换失败'; + msg.style.color = '#E05555'; + } + }); + }); + + $('save-html-btn').addEventListener('click', function () { + htmlTemplates[currentHtmlIndex] = $('custom-html-editor').value; + var msg = $('mode-msg'); + var url = currentHtmlIndex === 0 ? '/api/display/custom_html' : '/api/display/custom_html/' + currentHtmlIndex; + api('PUT', url, { html: htmlTemplates[currentHtmlIndex] }, function (res) { + if (res.ok) { + msg.textContent = 'HTML ' + currentHtmlIndex + ' 已保存'; + msg.style.color = '#2e7d32'; + } else { + msg.textContent = res.error || '保存失败'; + msg.style.color = '#E05555'; + } + }); + }); + + $('apply-html-btn').addEventListener('click', function () { + htmlTemplates[currentHtmlIndex] = $('custom-html-editor').value; + var msg = $('mode-msg'); + // First save current template + var saveUrl = currentHtmlIndex === 0 ? '/api/display/custom_html' : '/api/display/custom_html/' + currentHtmlIndex; + api('PUT', saveUrl, { html: htmlTemplates[currentHtmlIndex] }, function () { + // Then apply it + api('POST', '/api/display/apply_html/' + currentHtmlIndex, null, function (res) { + if (res.ok) { + msg.textContent = '已应用 HTML ' + currentHtmlIndex + ' 到显示端'; + msg.style.color = '#2e7d32'; + $('display-mode').value = 'custom'; + } else { + msg.textContent = res.error || '应用失败'; + msg.style.color = '#E05555'; + } + }); + }); + }); + + $('refresh-display-btn').addEventListener('click', function () { + var msg = $('mode-msg'); + api('POST', '/api/display/refresh', null, function (res) { + if (res.ok) { + msg.textContent = '刷新指令已发送'; + msg.style.color = '#2e7d32'; + } else { + msg.textContent = res.error || '发送失败'; + msg.style.color = '#E05555'; + } + }); + }); + + $('kick-all-btn').addEventListener('click', function () { + var msg = $('mode-msg'); + api('POST', '/api/display/kick_all', null, function (res) { + if (res.ok) { + msg.textContent = '已踢掉所有显示端'; + msg.style.color = '#2e7d32'; + } else { + msg.textContent = res.error || '操作失败'; + msg.style.color = '#E05555'; + } + }); + }); + + // ─── Timezone ────────────────────────────── + function loadTimezone() { + api('GET', '/api/time/config', null, function (cfg) { + $('timezone-input').value = cfg.timezone || 'Asia/Shanghai'; + }); + } + + $('timezone-form').addEventListener('submit', function (e) { + e.preventDefault(); + var msg = $('timezone-msg'); + var tz = $('timezone-input').value.trim(); + if (!tz) { msg.textContent = '请输入时区'; return; } + msg.textContent = '保存中...'; + api('PUT', '/api/time/config', { timezone: tz }, function (res) { + if (res.ok) { + msg.textContent = '✅ 时区已更新,将在下次请求时生效'; + msg.style.color = '#2e7d32'; + } else { + msg.textContent = res.error || '保存失败'; + msg.style.color = '#E05555'; + } + }); + }); + + // ─── Logout ──────────────────────────────── + $('logout-btn').addEventListener('click', function () { + api('POST', '/api/auth/logout', null, function () { + location.href = '/login?view=control'; + }); + }); + + // ─── Manual Notification ────────────────── + $('notify-form').addEventListener('submit', function (e) { + e.preventDefault(); + var msg = $('notify-msg'); + api('POST', '/api/display/notify', { + title: $('notify-title').value, + message: $('notify-message').value, + sound: $('notify-sound').checked, + }, function (res) { + if (res.ok) { + msg.textContent = '提醒已推送'; + msg.style.color = '#2e7d32'; + $('notify-message').value = ''; + } else { + msg.textContent = res.error || '推送失败'; + msg.style.color = '#E05555'; + } + }); + }); + + // ─── Pomodoro ───────────────────────────── + function loadPomodoro() { + api('GET', '/api/pomodoro', null, function (p) { + var statusEl = $('pomodoro-status'); + if (!statusEl) return; + if (!p.enabled) { + statusEl.textContent = '状态: 未启动'; + } else { + var phase = p.phase === 'work' ? '工作' : '休息'; + var remaining = p.remaining_seconds || 0; + if (p.running && p.end_time) { + try { + // end_time is a Unix timestamp (e.g. "1785285282.447") + remaining = Math.max(0, Math.floor(parseFloat(p.end_time) - Date.now() / 1000)); + } catch (_) {} + } + var remainingMinutes = Math.max(0, Math.ceil(remaining / 60)); + statusEl.textContent = '状态: ' + (p.running ? '进行中' : '已暂停') + ' | 阶段: ' + phase + ' | 剩余: ' + remainingMinutes + ' 分钟 | 已完成 ' + (p.cycles || 0) + ' 组 | 下次: ' + (p.next_break_type === 'long' ? '长休息' : '短休息'); + } + // Only update input values if user is not currently editing them + var workInput = $('pomodoro-work'); + var breakInput = $('pomodoro-break'); + var longBreakInput = $('pomodoro-long-break'); + var cyclesBeforeLongInput = $('pomodoro-cycles-before-long'); + var displayControlsInput = $('pomodoro-display-controls'); + var workLabelInput = $('pomodoro-work-label'); + var breakLabelInput = $('pomodoro-break-label'); + if (document.activeElement !== workInput) { + workInput.value = p.work_minutes || 25; + } + if (document.activeElement !== breakInput) { + breakInput.value = p.break_minutes || 5; + } + if (document.activeElement !== longBreakInput) { + longBreakInput.value = p.long_break_minutes || 15; + } + if (document.activeElement !== cyclesBeforeLongInput) { + cyclesBeforeLongInput.value = p.cycles_before_long_break || 4; + } + if (document.activeElement !== displayControlsInput) { + displayControlsInput.checked = p.display_controls_enabled !== false; + } + if (document.activeElement !== workLabelInput) { + workLabelInput.value = p.work_label || ''; + } + if (document.activeElement !== breakLabelInput) { + breakLabelInput.value = p.break_label || ''; + } + }); + } + + $('pomodoro-form').addEventListener('submit', function (e) { + e.preventDefault(); + api('POST', '/api/pomodoro', { + work_minutes: parseInt($('pomodoro-work').value) || 25, + break_minutes: parseInt($('pomodoro-break').value) || 5, + long_break_minutes: parseInt($('pomodoro-long-break').value) || 15, + cycles_before_long_break: parseInt($('pomodoro-cycles-before-long').value) || 4, + display_controls_enabled: $('pomodoro-display-controls').checked, + work_label: $('pomodoro-work-label').value || '工作中', + break_label: $('pomodoro-break-label').value || '休息中', + }, function () { + toast('番茄钟设置已保存'); + loadPomodoro(); + }); + }); + + $('pomodoro-start-btn').addEventListener('click', function () { + api('POST', '/api/pomodoro/start', null, function () { + toast('番茄钟已开始'); + loadPomodoro(); + }); + }); + + $('pomodoro-pause-btn').addEventListener('click', function () { + api('POST', '/api/pomodoro/pause', null, function () { + toast('番茄钟已暂停'); + loadPomodoro(); + }); + }); + + $('pomodoro-skip-btn').addEventListener('click', function () { + api('POST', '/api/pomodoro/skip', null, function () { + toast('已跳过当前阶段'); + loadPomodoro(); + }); + }); + + $('pomodoro-stop-btn').addEventListener('click', function () { + api('POST', '/api/pomodoro/stop', null, function () { + toast('番茄钟已停止'); + loadPomodoro(); + }); + }); + + + // ─── Inline Group Events ────────────────── + function loadInlineGroupEvents(groupId) { + api('GET', '/api/calendar/groups/' + groupId + '/events', null, function (events) { + var container = document.createElement('div'); + container.id = 'group-events-' + groupId; + container.style.cssText = 'padding:8px 12px;background:#f8f9fa;border-radius:6px;margin-top:8px;'; + var html = '
日程列表
'; + if (events.length === 0) { + html += '
暂无日程
'; + } else { + events.forEach(function (ev) { + var start = ev.dtstart || ''; + var end = ev.dtend || ''; + var startStr = start.length >= 13 ? start.substring(0, 4) + '-' + start.substring(4, 6) + '-' + start.substring(6, 8) + ' ' + start.substring(9, 11) + ':' + start.substring(11, 13) : start; + var endStr = end.length >= 13 ? end.substring(0, 4) + '-' + end.substring(4, 6) + '-' + end.substring(6, 8) + ' ' + end.substring(9, 11) + ':' + end.substring(11, 13) : end; + var timeRange = startStr + (endStr && endStr !== startStr ? ' 至 ' + endStr : ''); + html += '
' + + '
' + + '
' + _esc(ev.summary || '无标题') + '
' + + '
' + timeRange + (ev.location ? ' · ' + _esc(ev.location) : '') + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }); + } + container.innerHTML = html; + // Insert after the group-item + var groupEl = document.querySelector('.group-item [data-action="view-events"][data-id="' + groupId + '"]').closest('.group-item'); + groupEl.parentNode.insertBefore(container, groupEl.nextSibling); + bindInlineEventActions(groupId); + }); + } + + function bindInlineEventActions(groupId) { + var container = $('group-events-' + groupId); + if (!container) return; + container.querySelectorAll('[data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = btn.dataset.action; + var id = btn.dataset.id; + if (action === 'delete-inline-event') { + showConfirm('删除日程', '确认删除此日程?', function () { + api('DELETE', '/api/calendar/events/' + id, null, function () { + toast('已删除'); + loadInlineGroupEvents(groupId); + }); + }, { okText: '删除' }); + } else if (action === 'edit-inline-event') { + showPrompt('编辑日程', '日程标题', '', function (newSummary) { + if (newSummary === null || newSummary === '') return; + api('PUT', '/api/calendar/events/' + id, { summary: newSummary }, function (res) { + if (res.ok) { + toast('已更新'); + loadInlineGroupEvents(groupId); + } else { + toast(res.error || '更新失败'); + } + }); + }, { okText: '保存' }); + } + }); + }); + } + + // ─── Manual Event ───────────────────────── + function updateEventGroupSelects() { + api('GET', '/api/calendar/groups', null, function (groups) { + var selHtml = ''; + groups.forEach(function (g) { + selHtml += ''; + }); + ['event-group', 'browse-group'].forEach(function (id) { + var sel = $(id); + if (sel) sel.innerHTML = selHtml; + }); + }); + } + + $('event-form').addEventListener('submit', function (e) { + e.preventDefault(); + var dtstart = $('event-dtstart').value; + var dtend = $('event-dtend').value; + if (!dtstart) { toast('请选择开始时间'); return; } + // Convert from datetime-local (YYYY-MM-DDTHH:MM) to our format (YYYYMMDDTHHMMSS) + var startStr = dtstart.replace(/[-:]/g, '') + '00'; + var endStr = dtend ? dtend.replace(/[-:]/g, '') + '00' : null; + api('POST', '/api/calendar/events', { + group_id: parseInt($('event-group').value), + summary: $('event-summary').value, + dtstart: startStr, + dtend: endStr, + location: $('event-location').value, + }, function (res) { + if (res.id) { + toast('日程添加成功'); + $('event-summary').value = ''; + $('event-dtstart').value = ''; + $('event-dtend').value = ''; + $('event-location').value = ''; + } else { + toast(res.error || '添加失败'); + } + }); + }); + + // ─── Browse Group Events ────────────────── + function loadGroupEvents(groupId) { + api('GET', '/api/calendar/groups/' + groupId + '/events', null, function (events) { + var html = ''; + if (events.length === 0) { + html = '
暂无日程
'; + } else { + events.forEach(function (ev) { + var start = ev.dtstart || ''; + var end = ev.dtend || ''; + var startStr = start.length >= 13 ? start.substring(0, 4) + '-' + start.substring(4, 6) + '-' + start.substring(6, 8) + ' ' + start.substring(9, 11) + ':' + start.substring(11, 13) : start; + var endStr = end.length >= 13 ? end.substring(0, 4) + '-' + end.substring(4, 6) + '-' + end.substring(6, 8) + ' ' + end.substring(9, 11) + ':' + end.substring(11, 13) : end; + var timeRange = startStr + (endStr && endStr !== startStr ? ' 至 ' + endStr : ''); + html += '
' + + '
' + + '
' + _esc(ev.summary || '无标题') + '
' + + '
' + timeRange + (ev.location ? ' · ' + _esc(ev.location) : '') + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }); + } + $('group-events-list').innerHTML = html; + bindEventActions(); + }); + } + + function bindEventActions() { + document.querySelectorAll('#group-events-list [data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = btn.dataset.action; + var id = btn.dataset.id; + if (action === 'delete-event') { + showConfirm('删除日程', '确认删除此日程?', function () { + api('DELETE', '/api/calendar/events/' + id, null, function () { + toast('已删除'); + var gid = $('browse-group').value; + if (gid) loadGroupEvents(gid); + }); + }, { okText: '删除' }); + } else if (action === 'edit-event') { + showPrompt('编辑日程', '日程标题', decodeURIComponent(btn.dataset.summary || ''), function (newSummary) { + if (newSummary === null || newSummary === '') return; + showPrompt('编辑日程', '地点(可留空)', decodeURIComponent(btn.dataset.location || ''), function (newLocation) { + if (newLocation === null) return; + api('PUT', '/api/calendar/events/' + id, { + summary: newSummary, + location: newLocation, + }, function (res) { + if (res.ok) { + toast('已更新'); + var gid = $('browse-group').value; + if (gid) loadGroupEvents(gid); + } else { + toast(res.error || '更新失败'); + } + }); + }, { okText: '保存' }); + }, { okText: '下一步' }); + } + }); + }); + } + + $('browse-events-btn').addEventListener('click', function () { + var gid = $('browse-group').value; + if (gid) loadGroupEvents(gid); + }); + + // ─── MFA Management ──────────────────────── + function loadMFAStatus() { + api('GET', '/api/auth/mfa/status', null, function (s) { + var statusEl = $('mfa-status'); + if (!statusEl) return; + var methods = []; + if (s.passkey_enabled) methods.push('🔑 Passkey'); + if (s.totp_enabled) methods.push('📱 TOTP'); + if (methods.length > 0) { + statusEl.textContent = '已配置: ' + methods.join(', '); + statusEl.style.color = '#2e7d32'; + } else { + statusEl.textContent = '⚠️ 未配置任何 MFA 方式'; + statusEl.style.color = '#E05555'; + } + + // Show/hide remove buttons + var removePasskeyBtn = $('mfa-remove-passkey'); + var removeTotpBtn = $('mfa-remove-totp'); + if (removePasskeyBtn) removePasskeyBtn.style.display = s.passkey_enabled ? '' : 'none'; + if (removeTotpBtn) removeTotpBtn.style.display = s.totp_enabled ? '' : 'none'; + + // Show recovery codes if any + var recSection = $('mfa-recovery-section'); + if (s.recovery_codes_remaining > 0) { + loadRecoveryCodes(); + } else if (recSection) { + recSection.style.display = 'none'; + } + }); + } + + // ─── Passkey Registration (from settings) ─ + $('mfa-register-passkey').addEventListener('click', function () { + _reauthThen(function () { + var statusEl = $('mfa-passkey-status'); + statusEl.textContent = '正在准备注册...'; + + api('POST', '/api/auth/webauthn/register/begin', null, function (options) { + if (options.error) { + statusEl.textContent = '❌ ' + options.error; + return; + } + + // Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController + PasskeyBridge.creation(options) + .then(function (credential) { + credential.user_name = options.user && options.user.name ? options.user.name : 'user'; + + api('POST', '/api/auth/webauthn/register/complete', { credential: credential }, function (res) { + if (res.ok) { + statusEl.textContent = '✅ Passkey 注册成功'; + loadMFAStatus(); + } else { + statusEl.textContent = '❌ ' + (res.error || '注册失败'); + } + }); + }) + .catch(function (err) { + statusEl.textContent = '⏸️ ' + (err.message || '操作取消'); + }); + }); + }); + }); + + // ─── TOTP Setup ─────────────────────────── + $('mfa-setup-totp').addEventListener('click', function () { + _reauthThen(function () { + var area = $('mfa-totp-area'); + if (area.style.display === 'block') { + area.style.display = 'none'; + return; + } + + api('POST', '/api/auth/totp/setup', null, function (data) { + $('mfa-totp-qr').innerHTML = 'TOTP QR'; + $('mfa-totp-secret').textContent = '密钥: ' + data.secret; + area.style.display = 'block'; + }); + }); + }); + + $('mfa-totp-verify-btn').addEventListener('click', function () { + var code = $('mfa-totp-code').value; + var msgEl = $('mfa-totp-msg'); + + api('POST', '/api/auth/totp/verify', { code: code }, function (res) { + if (res.ok) { + msgEl.textContent = '✅ TOTP 已启用'; + msgEl.style.color = '#2e7d32'; + $('mfa-totp-area').style.display = 'none'; + $('mfa-totp-code').value = ''; + loadMFAStatus(); + // Show recovery codes if returned + if (res.recovery_codes && res.recovery_codes.length > 0) { + showRecoveryCodesUI(res.recovery_codes); + } + } else { + msgEl.textContent = res.error || '验证失败'; + msgEl.style.color = '#E05555'; + } + }); + }); + + // ─── Recovery Codes ─────────────────────── + function loadRecoveryCodes() { + api('GET', '/api/auth/recovery-codes', null, function (data) { + if (data.codes && data.codes.length > 0) { + showRecoveryCodesUI(data.codes); + } + }); + } + + function showRecoveryCodesUI(codes) { + var section = $('mfa-recovery-section'); + var container = $('mfa-recovery-codes'); + if (section) section.style.display = 'block'; + if (container) container.innerHTML = codes.map(function (c) { return '' + c + ' '; }).join(''); + } + + $('mfa-copy-recovery').addEventListener('click', function () { + var text = $('mfa-recovery-codes').textContent; + navigator.clipboard.writeText(text).then(function () { + toast('恢复码已复制'); + }); + }); + + $('mfa-regenerate-recovery').addEventListener('click', function () { + _reauthThen(function () { + showConfirm('重新生成恢复码', '重新生成将使旧的恢复码全部失效,确认?', function () { + var msgEl = $('mfa-recovery-msg'); + api('POST', '/api/auth/recovery-codes/regenerate', null, function (data) { + if (data.codes) { + showRecoveryCodesUI(data.codes); + msgEl.textContent = '✅ 已重新生成'; + msgEl.style.color = '#2e7d32'; + } + }); + }, { okText: '重新生成' }); + }); + }); + + // ─── Remove MFA (future: passkey & totp removal) ─ + // For now, user can simply re-register to override + + // ─── Helpers ────────────────────────────── + // Convert base64url (from server/webauthn) to ArrayBuffer + function _base64ToArrayBuffer(b64) { + var base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) base64 += '='; + var binary = atob(base64); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } + + // Convert ArrayBuffer to base64url (for WebAuthn API) + function _arrayBufferToBase64(buf) { + var bytes = new Uint8Array(buf); + var binary = ''; + for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + // ─── Theme 设置 ────────────────────────── + function loadThemeSetting() { + api('GET', '/api/theme', null, function (d) { + var sel = $('theme-mode'); + if (sel && d.mode) sel.value = d.mode; + }); + } + + $('theme-save-btn').addEventListener('click', function () { + var mode = $('theme-mode').value; + api('PUT', '/api/theme', { mode: mode }, function (d) { + if (d.ok) { + if (window.ThemeBridge && d.effective) ThemeBridge.apply(d.effective); + toast('主题已保存并同步到显示端'); + } else { + toast(d.error || '保存失败'); + } + }); + }); + + // ─── Current user + admin visibility ────── + function loadAuthStatus() { + api('GET', '/api/auth/status', null, function (status) { + var badge = $('current-user-badge'); + if (badge && status.user) { + badge.textContent = '· ' + (status.user.remark || '用户'); + } + if (status.user && status.user.is_admin) { + var tab = $('users-tab'); + if (tab) tab.style.display = ''; + loadUsers(); + } + }); + } + + // ─── User Management (admin only) ───────── + var _totpTimer = null; + + function showModal(title, html) { + closeModal(); + var overlay = document.createElement('div'); + overlay.id = 'ux-modal'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:999;display:flex;align-items:center;justify-content:center;'; + overlay.innerHTML = '
' + + '

' + title + '

' + + '
' + html + '
' + + '
' + + '' + + '
'; + document.body.appendChild(overlay); + overlay.querySelector('#ux-modal-close').addEventListener('click', closeModal); + overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); }); + } + + function closeModal() { + if (_totpTimer) { clearInterval(_totpTimer); _totpTimer = null; } + var m = $('ux-modal'); + if (m) m.remove(); + } + + // ─── 自定义对话框(替代浏览器 prompt/confirm/alert)─── + function showConfirm(title, message, onOk, opts) { + opts = opts || {}; + var okText = opts.okText || '确认'; + var danger = opts.danger !== false; + showModal(title, + '
' + message + '
' + + '
' + + '' + + '' + + '
'); + var closeBtn = $('ux-modal-close'); + if (closeBtn) closeBtn.style.display = 'none'; + $('ux-confirm-ok').addEventListener('click', function () { closeModal(); if (onOk) onOk(); }); + $('ux-confirm-cancel').addEventListener('click', closeModal); + } + + function showPrompt(title, placeholder, defaultValue, onOk, opts) { + opts = opts || {}; + var inputType = opts.inputType || 'text'; + var okText = opts.okText || '确定'; + showModal(title, + '' + + '
' + + '
' + + '' + + '' + + '
'); + var closeBtn = $('ux-modal-close'); + if (closeBtn) closeBtn.style.display = 'none'; + var input = $('ux-prompt-input'); + function submit() { + closeModal(); + if (onOk) onOk(input.value); + } + $('ux-prompt-ok').addEventListener('click', submit); + $('ux-prompt-cancel').addEventListener('click', function () { closeModal(); if (onOk) onOk(null); }); + input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); }); + input.focus(); + input.select(); + } + + function loadUsers() { + if (!$('users-tab') || $('users-tab').style.display === 'none') return; + api('GET', '/api/admin/users', null, function (users) { + var pending = users.filter(function (u) { return u.status === 'pending'; }); + var active = users.filter(function (u) { return u.status === 'active'; }); + + // Pending requests + var pendHtml = ''; + if (pending.length > 0) { + pendHtml = '

⏳ 待确认申请(用户自助填写信息)

'; + pending.forEach(function (u) { + pendHtml += '
' + + '
' + _esc(u.remark) + ' ' + _esc(u.created_at || '') + '
' + + '
' + + '' + + '' + + '
'; + }); + } + $('pending-requests').innerHTML = pendHtml; + + // Active users + var html = '

👥 用户列表

'; + if (active.length === 0) html += '
暂无用户
'; + active.forEach(function (u) { + var badges = []; + if (u.role === 'admin') badges.push('管理员'); + if (u.is_temp) badges.push('临时账户'); + if (u.has_mfa) badges.push('已配置 MFA'); + html += '
' + + '
' + + '' + _esc(u.remark) + ' ' + badges.join(' ') + + '
创建于 ' + _esc(u.created_at || '') + '
' + + '
' + + '
' + + (u.role !== 'admin' ? '' : '') + + (u.is_temp ? '' : '') + + (u.role !== 'admin' && !u.is_temp ? '' + + '' : '') + + (u.role !== 'admin' ? '' : '') + + '
'; + }); + $('user-list').innerHTML = html; + + bindUserActions(); + }); + } + + function bindUserActions() { + document.querySelectorAll('#pending-requests [data-action], #user-list [data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = btn.dataset.action; + var id = parseInt(btn.dataset.id, 10); + if (action === 'confirm-request') { + _reauthThen(function () { + api('POST', '/api/admin/users/confirm', { request_id: id }, function (res) { + if (res.ok) { toast('已生成临时账户'); loadUsers(); } else { toast(res.error || '操作失败'); } + }); + }); + } else if (action === 'delete-request') { + _reauthThen(function () { + api('DELETE', '/api/admin/users/' + id, null, function (res) { + if (res.ok) { toast('已拒绝该申请'); loadUsers(); } else { toast(res.error || '操作失败'); } + }); + }); + } else if (action === 'edit-remark') { + _reauthThen(function () { + showPrompt('编辑用户备注', '新的备注', '', function (newRemark) { + if (newRemark === null || !newRemark.trim()) return; + api('PUT', '/api/admin/users/' + id, { remark: newRemark.trim() }, function (res) { + if (res.ok) { toast('备注已更新'); loadUsers(); } else { toast(res.error || '更新失败'); } + }); + }, { okText: '保存' }); + }); + } else if (action === 'delete-user') { + showConfirm('删除用户', '确认删除该用户及其全部数据?此操作不可恢复!', function () { + _reauthThen(function () { + api('DELETE', '/api/admin/users/' + id, null, function (res) { + if (res.ok) { toast('用户已删除'); loadUsers(); } else { toast(res.error || '删除失败'); } + }); + }); + }, { okText: '删除' }); + } else if (action === 'show-temp-totp') { + showTempTotp(id); + } else if (action === 'provision-totp') { + _reauthThen(function () { provisionTotp(id); }); + } else if (action === 'provision-passkey') { + _reauthThen(function () { provisionPasskey(id); }); + } + }); + }); + } + + // Real-time temp TOTP (admin helper — secret stays on server). + // 高危操作:查看前必须先完成二次验证;轮询期间验证过期则停止并提示。 + function showTempTotp(userId) { + _reauthThen(function () { + showModal('临时账户实时 TOTP', '
将该实时验证码发给用户,用于首次登录并配置登录方式。
' + + '
—
' + + '
每 30 秒自动刷新
'); + function refresh() { + api('GET', '/api/admin/users/' + userId + '/totp', null, function (res) { + var el = $('temp-totp-code'); + if (!el) return; + var tip = $('temp-totp-tip'); + if (res && res.code) { + el.textContent = res.code; + if (tip) { tip.textContent = '每 30 秒自动刷新'; tip.style.color = ''; } + } else if (res && res.reauth_required) { + if (_totpTimer) { clearInterval(_totpTimer); _totpTimer = null; } + el.textContent = '—'; + if (tip) { tip.textContent = '⚠️ 身份验证已过期,请关闭后重新查看'; tip.style.color = '#E05555'; } + } else { + el.textContent = (res && res.error) || '—'; + } + }); + } + refresh(); + _totpTimer = setInterval(refresh, 5000); + }); + } + + // Admin-fills TOTP provisioning: show QR + secret; admin verifies the user's code. + function provisionTotp(userId) { + api('POST', '/api/admin/users/' + userId + '/totp/begin', null, function (data) { + if (data.error) { toast(data.error); return; } + showModal('为该用户配置 TOTP', '
将此二维码/密钥转交给用户添加到身份验证器应用,然后输入用户手机上的 6 位验证码完成启用。
' + + '
' + + '
密钥: ' + data.secret + '
' + + '
' + + '' + + '
' + + '
'); + $('admin-totp-verify').addEventListener('click', function () { + var code = $('admin-totp-code').value.trim(); + if (!code) return; + api('POST', '/api/admin/users/' + userId + '/totp/verify', { code: code }, function (res) { + var msg = $('admin-totp-msg'); + if (res.ok) { + msg.textContent = '✅ TOTP 已启用'; + msg.style.color = '#2e7d32'; + setTimeout(closeModal, 1200); + loadUsers(); + } else { + msg.textContent = res.error || '验证失败'; + msg.style.color = '#E05555'; + } + }); + }); + }); + } + + // Admin-fills Passkey provisioning: ceremony completes in the admin's browser. + function provisionPasskey(userId) { + api('POST', '/api/admin/users/' + userId + '/passkey/begin', null, function (options) { + if (options.error) { toast(options.error); return; } + // Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController + PasskeyBridge.creation(options) + .then(function (credential) { + credential.user_name = 'user_' + userId; + api('POST', '/api/admin/users/' + userId + '/passkey/complete', { credential: credential }, function (res) { + if (res.ok) { toast('✅ Passkey 已为该用户注册(保存在当前设备)'); loadUsers(); } + else { toast(res.error || '注册失败'); } + }); + }) + .catch(function (err) { + toast('Passkey 注册已取消'); + }); + }); + } + + // ─── Admin register dialogs ─────────────── + $('user-add-admin').addEventListener('click', function () { + showModal('管理员代注册', '
由管理员填写用户信息,账户即刻生效。随后可为其配置 TOTP 或 Passkey。
' + + '' + + '' + + '
' + + '' + + '
'); + $('admin-add-ok').addEventListener('click', function () { + var remark = $('admin-add-remark').value.trim(); + _reauthThen(function () { + api('POST', '/api/admin/users', { remark: remark, mode: 'admin_fills' }, function (res) { + if (res.ok) { + toast('账户已创建'); + closeModal(); + loadUsers(); + } else { + toast(res.error || '创建失败'); + } + }); + }); + }); + }); + + $('user-add-temp').addEventListener('click', function () { + showModal('创建临时账户(用户自助填写)', '
系统生成临时 TOTP(密钥仅存于服务器)。创建后点击用户列表中的「实时TOTP」获取实时验证码交给用户;用户用它登录后会被强制引导配置自己的登录方式,配置完成前临时 TOTP 不会失效。
' + + '' + + '' + + '
' + + '' + + '
'); + $('temp-add-ok').addEventListener('click', function () { + var remark = $('temp-add-remark').value.trim(); + _reauthThen(function () { + api('POST', '/api/admin/users', { remark: remark, mode: 'user_fills' }, function (res) { + if (res.ok) { + toast('临时账户已创建'); + closeModal(); + loadUsers(); + } else { + toast(res.error || '创建失败'); + } + }); + }); + }); + }); + + $('user-refresh-btn').addEventListener('click', loadUsers); + + // ─── Init ───────────────────────────────── + loadGroups(); + updateEventGroupSelects(); + loadWeatherConfig(); + loadAlarms(); + loadPomodoro(); + loadDisplayMode(); + loadTimezone(); + loadMFAStatus(); + loadAuthStatus(); + loadThemeSetting(); + + // Refresh pomodoro status every minute when on pomodoro tab + setInterval(function () { + var tab = document.querySelector('.tab.active'); + if (tab && tab.dataset.tab === 'pomodoro') { + loadPomodoro(); + } + }, 60000); + +})(); diff --git a/frontend/static/js/display.js b/frontend/static/js/display.js new file mode 100644 index 0000000..838095e --- /dev/null +++ b/frontend/static/js/display.js @@ -0,0 +1,1603 @@ +/* Display page - pure vanilla JS, minimal and fast */ +(function () { + 'use strict'; + + // 自定义提示(替代浏览器 alert):轻量浮层,自动消失 + function showAlert(msg) { + var el = document.createElement('div'); + el.style.cssText = 'position:fixed;top:56px;left:50%;transform:translateX(-50%);z-index:9999;' + + 'background:#fff;color:#E05555;border:1px solid #f0c2c2;border-radius:12px;' + + 'padding:12px 24px;font-size:15px;box-shadow:0 6px 24px rgba(0,0,0,.18);'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(function () { el.remove(); }, 2200); + } + + // ─── State ──────────────────────────────── + var state = { + weather: {}, + events: [], + clocks: [], + alarms: [], + groups: [], + pomodoro: {}, + activePanel: 'time', + alarmActive: false, + pomodoroRinging: false, + customNotifyActive: false, + audioCtx: null, + editingAlarm: null, // alarm object being edited, or null for new + _alarmModalOpen: false, + countdowns: [], + hourly: [], + minutely: null, + warnings: null, + update_schedule: {}, + _cdModalMode: false, // true = countdown modal, false = alarm modal + _editingCountdown: null, + }; + + // ─── DOM refs ───────────────────────────── + var $ = function (id) { return document.getElementById(id); }; + var cards = document.querySelectorAll('.card'); + var panels = document.querySelectorAll('.panel-content'); + // HTML escape for untrusted data — prevents XSS in innerHTML + function _esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // ─── Panel switching ────────────────────── + function switchPanel(name) { + if (state.alarmActive && name !== 'alarm') return; // lock during alarm + state.activePanel = name; + cards.forEach(function (c) { c.classList.remove('active'); }); + panels.forEach(function (p) { p.classList.remove('active'); }); + var card = document.querySelector('[data-card="' + name + '"]'); + var panel = $('panel-' + name); + if (card) card.classList.add('active'); + if (panel) panel.classList.add('active'); + // Re-render pomodoro card to show/hide buttons based on active panel + renderPomodoroCard(); + // Refresh pomodoro state when switching to pomodoro panel + if (name === 'pomodoro') { + fetchPomodoro(); + } + } + + cards.forEach(function (card) { + card.addEventListener('click', function () { + switchPanel(this.dataset.card); + }); + }); + + // ─── Data fetching ──────────────────────── + function fetchState() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/api/display/state'); + xhr.onload = function () { + if (xhr.status === 401) { + var res = JSON.parse(xhr.responseText || '{}'); + if (res.login_required) { + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + return; + } + } + if (xhr.status === 200) { + var data = JSON.parse(xhr.responseText); + state.weather = data.weather || {}; + state.hourly = data.hourly || []; + state.events = data.events || []; + state.alarms = data.alarms || []; + state.countdowns = data.countdowns || []; + state.update_schedule = data.update_schedule || {}; + state.minutely = data.minutely || null; + state.warnings = data.warnings || null; + state.groups = data.groups || []; + renderAll(); + } + }; + xhr.send(); + } + + // ─── SSE for real-time events ───────────── + function connectSSE() { + var es = new EventSource('/api/events'); + es.onmessage = function (e) { + try { + var msg = JSON.parse(e.data); + handleEvent(msg); + } catch (_) {} + }; + es.onerror = function () { + es.close(); + setTimeout(connectSSE, 5000); + }; + // SSE doesn't give us status codes; if auth fails the browser will error-loop. + // We add a small guard: if we get no messages for 30s, try a normal fetch to check auth. + state._sseGuard = setInterval(function () { + var g = new XMLHttpRequest(); + g.open('GET', '/api/display/state'); + g.onload = function () { + if (g.status === 401) { + var res = JSON.parse(g.responseText || '{}'); + if (res.login_required) { + es.close(); + clearInterval(state._sseGuard); + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + } + } + }; + g.send(); + }, 30000); + } + + function handleEvent(msg) { + if (msg.type === 'alarm_triggered') { + fetchState(); + showAlarmOverlay(msg.data); + } else if (msg.type === 'pomodoro_updated') { + state.pomodoro = msg.data || {}; + renderPomodoro(); + } else if (msg.type === 'pomodoro_due') { + state.pomodoro = msg.data || {}; + renderPomodoro(); + showPomodoroOverlay(); + } else if (msg.type === 'calendar_updated') { + fetchState(); + var card = $('card-calendar'); + if (card) card.classList.add('alert'); + setTimeout(function () { if (card) card.classList.remove('alert'); }, 6000); + switchPanel('calendar'); + } else if (msg.type === 'weather_updated') { + fetchState(); + } else if (msg.type === 'reminder_due') { + var card = $('card-calendar'); + if (card) card.classList.add('alert'); + switchPanel('calendar'); + fetchState(); + playReminderSound(); + // 5秒后自动跳回时间页面并移除高亮 + setTimeout(function () { + if (card) card.classList.remove('alert'); + switchPanel('time'); + }, 5000); + } else if (msg.type === 'custom_notification') { + showCustomNotification(msg.data); + } else if (msg.type === 'countdown_triggered') { + fetchState(); + showAlarmOverlay(msg.data); // reuse alarm overlay UI + switchPanel('countdown'); + } else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') { + location.reload(); + } else if (msg.type === 'theme_changed') { + // 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画) + if (window.ThemeBridge) ThemeBridge.fetch(); + } else if (msg.type === 'kick_all') { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/logout'); + xhr.onload = function () { + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + }; + xhr.send(); + } + } + + // ─── Alarm Overlay & Sound ──────────────── + function showAlarmOverlay(data) { + if (state.alarmActive) return; // already showing + state.alarmActive = true; + + // Build info text (supports both alarm and countdown data) + var info = ''; + (data.alarms || []).forEach(function (a) { + info += a.time + (a.label ? ' ' + a.label : '') + '
'; + }); + (data.countdowns || []).forEach(function (cd) { + info += cd.target_time + (cd.label ? ' ' + cd.label : '') + '
'; + }); + + var overlay = $('alarm-overlay'); + var infoEl = $('alarm-overlay-info'); + var audio = $('alarm-audio'); + + if (infoEl) infoEl.innerHTML = info || '闹钟响了'; + if (overlay) overlay.classList.remove('hidden'); + + // Highlight the alarm card + var card = $('card-alarm'); + if (card) card.classList.add('alert'); + + // Switch panel + switchPanel('alarm'); + + // Try custom ringtone first + var hasCustomRingtone = false; + (data.alarms || []).forEach(function (a) { + if (a.sound && a.sound !== 'default') { + audio.src = '/static/ringtones/' + encodeURIComponent(a.sound); + audio.play().catch(function () { playDefaultAlarm(); }); + hasCustomRingtone = true; + } + }); + + if (!hasCustomRingtone) { + playDefaultAlarm(); + } + } + + function playDefaultAlarm() { + // Generate alarm sound with Web Audio API + try { + state.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } catch (_) { + return; + } + + // Create a two-tone alarm beep pattern + var ctx = state.audioCtx; + var beepDuration = 0.15; // seconds per beep + var beepGap = 0.1; + var cycleTime = (beepDuration * 2 + beepGap * 2); + var startTime = ctx.currentTime; + + function scheduleBeep(t, freq) { + var osc = ctx.createOscillator(); + var gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.3, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + beepDuration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + beepDuration); + } + + function scheduleCycle(cycleStart) { + scheduleBeep(cycleStart, 880); // A5 + scheduleBeep(cycleStart + beepDuration + beepGap, 660); // E5 + } + + // Schedule 60 seconds of alarm (will be stopped on dismiss) + var cycles = Math.floor(60 / cycleTime); + for (var i = 0; i < cycles; i++) { + scheduleCycle(startTime + i * cycleTime); + } + + // Re-schedule if still active + state._alarmTimer = setInterval(function () { + if (!state.alarmActive) { clearInterval(state._alarmTimer); return; } + var t = state.audioCtx.currentTime; + for (var i = 0; i < 10; i++) { + scheduleCycle(t + i * cycleTime); + } + }, 5000); + } + + function playReminderSound() { + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + var t = ctx.currentTime; + // 轻柔的三声提示音 (增益 1.0) + [0, 0.3, 0.6].forEach(function (offset) { + var osc = ctx.createOscillator(); + var gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.setValueAtTime(1.0, t + offset); + gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.15); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t + offset); + osc.stop(t + offset + 0.15); + }); + setTimeout(function () { ctx.close(); }, 2000); + } catch (_) {} + } + + function dismissAlarm() { + state.alarmActive = false; + + var overlay = $('alarm-overlay'); + var audio = $('alarm-audio'); + var card = $('card-alarm'); + + if (overlay) overlay.classList.add('hidden'); + if (card) card.classList.remove('alert'); + + // Stop audio + if (audio) { audio.pause(); audio.src = ''; } + if (state.audioCtx) { + try { state.audioCtx.close(); } catch (_) {} + state.audioCtx = null; + } + if (state._alarmTimer) { + clearInterval(state._alarmTimer); + state._alarmTimer = null; + } + + // Return to time panel + switchPanel('time'); + fetchState(); + } + + // ─── Pomodoro Overlay & Sound ───────────── + function showPomodoroOverlay() { + if (state.pomodoroRinging) return; + state.pomodoroRinging = true; + + var overlay = $('pomodoro-overlay'); + var titleEl = $('pomodoro-overlay-title'); + var infoEl = $('pomodoro-overlay-info'); + // dismiss() has already advanced phase, so invert the mapping: + // phase=="work" means break just finished, phase=="break" means work just finished. + var phase = state.pomodoro.phase === 'work' ? '休息结束' : '工作结束'; + var nextPhase = state.pomodoro.phase === 'work' ? '进入工作' : '进入休息'; + + if (titleEl) titleEl.textContent = '番茄钟 - ' + phase; + if (infoEl) infoEl.textContent = nextPhase; + if (overlay) overlay.classList.remove('hidden'); + + var card = $('card-pomodoro'); + if (card) card.classList.add('alert'); + switchPanel('pomodoro'); + + playPomodoroRingtone(); + + // Auto dismiss after 3 rings (~3 seconds) + state._pomodoroAutoDismiss = setTimeout(function () { + dismissPomodoro(); + }, 3500); + } + + function playPomodoroRingtone() { + // Play calendar-like reminder sound: three gentle beeps, gain 1.0 + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + state._pomodoroAudioCtx = ctx; + var t = ctx.currentTime; + [0, 0.3, 0.6].forEach(function (offset) { + var osc = ctx.createOscillator(); + var gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.setValueAtTime(1.0, t + offset); + gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.4); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t + offset); + osc.stop(t + offset + 0.4); + }); + } catch (_) {} + } + + function dismissPomodoro() { + state.pomodoroRinging = false; + + var overlay = $('pomodoro-overlay'); + var card = $('card-pomodoro'); + if (overlay) overlay.classList.add('hidden'); + if (card) card.classList.remove('alert'); + + if (state._pomodoroAudioCtx) { + try { state._pomodoroAudioCtx.close(); } catch (_) {} + state._pomodoroAudioCtx = null; + } + if (state._pomodoroAutoDismiss) { + clearTimeout(state._pomodoroAutoDismiss); + state._pomodoroAutoDismiss = null; + } + + // Phase advancement is now handled by the backend when the timer expires. + switchPanel('pomodoro'); + } + + var pomodoroDismissBtn = $('pomodoro-dismiss'); + if (pomodoroDismissBtn) { + pomodoroDismissBtn.addEventListener('click', dismissPomodoro); + } + + // ─── Alarm CRUD ─────────────────────────── + function apiJson(method, url, data, callback) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + if (data) { xhr.setRequestHeader('Content-Type', 'application/json'); } + xhr.onload = function () { + if (xhr.status === 401) { + var res = JSON.parse(xhr.responseText || '{}'); + if (res.login_required) { + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + return; + } + } + if (callback) callback(JSON.parse(xhr.responseText || '{}')); + }; + xhr.send(data ? JSON.stringify(data) : null); + } + + function deleteAlarm(id) { + apiJson('DELETE', '/api/alarms/' + id, null, function () { + fetchState(); + }); + } + + function _nextAlarmName() { + // Find next available "闹钟 N" name + var used = {}; + state.alarms.forEach(function (a) { + var m = (a.label || '').match(/^闹钟 (\d+)$/); + if (m) used[parseInt(m[1], 10)] = true; + }); + var n = 1; + while (used[n]) { n++; } + return '闹钟 ' + n; + } + + // ─── Alarm Modal (mouse-only time picker) ─ + function openAlarmModal(alarm) { + if (state._alarmModalOpen) return; + state._alarmModalOpen = true; + state.editingAlarm = alarm || null; + state._cdModalMode = false; + + var modal = $('alarm-modal'); + var titleEl = $('alarm-modal-title'); + var nameEl = $('alarm-modal-name'); + var saveBtn = $('alarm-modal-save'); + + // Title and name + if (alarm) { + titleEl.textContent = '编辑闹钟'; + nameEl.textContent = alarm.label || ''; + saveBtn.textContent = '保存修改'; + } else { + titleEl.textContent = '添加闹钟'; + nameEl.textContent = _nextAlarmName(); + saveBtn.textContent = '添加'; + } + + // Set time + var timeStr = alarm ? alarm.time : '08:00'; + var parts = timeStr.split(':'); + $('tp-hours').textContent = parts[0] || '08'; + $('tp-minutes').textContent = parts[1] || '00'; + $('tp-seconds').textContent = '00'; // not used for alarms + + // Set days + var days = alarm ? (alarm.days || '1,2,3,4,5,6,7').split(',') : ['1','2','3','4','5','6','7']; + document.querySelectorAll('#alarm-modal-days .day-btn').forEach(function (btn) { + btn.classList.toggle('active', days.indexOf(btn.dataset.day) !== -1); + }); + + // Set enabled + once + $('alarm-modal-enabled').checked = alarm ? alarm.enabled !== false : true; + $('alarm-modal-once').checked = alarm ? !!alarm.once : false; + _updateAlarmDaysVisibility(); + + // Show alarm-only fields, hide countdown-only fields + $('alarm-once-field').style.display = ''; + $('alarm-days-field').style.display = ''; + document.querySelector('#alarm-modal .modal-label[data-cd-label]') && + (document.querySelector('#alarm-modal .modal-label[data-cd-label]').style.display = 'none'); + + modal.classList.remove('hidden'); + } + + function _updateAlarmDaysVisibility() { + var isOnce = $('alarm-modal-once').checked; + $('alarm-days-field').style.display = isOnce ? 'none' : ''; + } + + function closeAlarmModal() { + state._alarmModalOpen = false; + state.editingAlarm = null; + $('alarm-modal').classList.add('hidden'); + } + + function saveAlarmFromModal() { + var hours = parseInt($('tp-hours').textContent, 10); + var minutes = parseInt($('tp-minutes').textContent, 10); + if (isNaN(hours) || isNaN(minutes)) return; + + var time = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0'); + + var days = []; + document.querySelectorAll('#alarm-modal-days .day-btn.active').forEach(function (btn) { + days.push(btn.dataset.day); + }); + if (days.length === 0) { showAlert('请至少选择一天'); return; } + + var enabled = $('alarm-modal-enabled').checked; + var once = $('alarm-modal-once').checked; + // For once-only alarms, days are irrelevant + if (once) days = ['1','2','3','4','5','6','7']; + + var editing = state.editingAlarm; + if (editing) { + // Edit: update existing alarm (name NOT changed) + apiJson('PUT', '/api/alarms/' + editing.id, { + time: time, + days: days.join(','), + enabled: enabled, + once: once, + }, function () { + closeAlarmModal(); + fetchState(); + }); + } else { + // Add: create new alarm with auto-generated name + apiJson('POST', '/api/alarms', { + label: _nextAlarmName(), + time: time, + days: days.join(','), + enabled: enabled, + once: once, + }, function () { + closeAlarmModal(); + fetchState(); + }); + } + } + + // ─── Countdown (duration-based, independent of alarms) ─ + function _parseEndTime(endStr) { + if (!endStr) return null; + try { + // end_time is a Unix timestamp (e.g. "1785285282.447") + return new Date(parseFloat(endStr) * 1000); + } catch (_) { return null; } + } + + function renderCountdownCard() { + var now = new Date(); + var nearest = null; + var nearestSec = null; + + state.countdowns.forEach(function (cd) { + if (!cd.enabled) return; + var endDate = _parseEndTime(cd.end_time); + if (!endDate) return; + var remaining = Math.max(0, Math.floor((endDate - now) / 1000)); + if (nearestSec === null || remaining < nearestSec) { + nearest = cd; + nearestSec = remaining; + } + }); + + var el = $('countdown-next'); + if (nearest && nearestSec !== null && nearestSec > 0) { + if (nearestSec < 60) { + el.textContent = nearestSec + '秒'; + } else if (nearestSec < 3600) { + el.textContent = Math.floor(nearestSec / 60) + '分'; + } else { + el.textContent = Math.floor(nearestSec / 3600) + '时' + + String(Math.floor((nearestSec % 3600) / 60)).padStart(2, '0') + '分'; + } + } else { + el.textContent = '无'; + } + } + + function renderCountdownPanel() { + var list = $('countdown-list'); + if (!list) return; + + var now = new Date(); + var items = []; + + state.countdowns.forEach(function (cd) { + if (!cd.enabled) return; + var endDate = _parseEndTime(cd.end_time); + if (!endDate) return; + var remaining = Math.max(0, Math.floor((endDate - now) / 1000)); + items.push({ countdown: cd, remainingSec: remaining, endDate: endDate }); + }); + + // Sort by soonest first + items.sort(function (x, y) { return x.remainingSec - y.remainingSec; }); + + if (items.length === 0) { + list.innerHTML = '
暂无活跃倒计时
'; + return; + } + + var html = ''; + + items.forEach(function (item) { + var cd = item.countdown; + var remainingSec = item.remainingSec; + + var hours = Math.floor(remainingSec / 3600); + var mins = Math.floor((remainingSec % 3600) / 60); + var secs = remainingSec % 60; + + var remainStr; + var overdue = false; + if (remainingSec <= 0) { + remainStr = '已结束'; + overdue = true; + } else { + remainStr = + String(hours).padStart(2, '0') + ':' + + String(mins).padStart(2, '0') + ':' + + String(secs).padStart(2, '0'); + } + + var dur = cd.duration_seconds || 0; + var durStr = String(Math.floor(dur / 3600)).padStart(2, '0') + ':' + + String(Math.floor((dur % 3600) / 60)).padStart(2, '0') + ':' + + String(dur % 60).padStart(2, '0'); + + html += '
' + + '
' + + '
' + durStr + '
' + + (cd.label ? '
' + cd.label + '
' : '') + + '
' + + '
' + remainStr + '
' + + '
' + + '' + + '' + + '
'; + }); + + list.innerHTML = html; + + // Bind edit buttons + list.querySelectorAll('[data-action="edit-cd"]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var id = parseInt(btn.dataset.id, 10); + var cd = null; + for (var i = 0; i < state.countdowns.length; i++) { + if (state.countdowns[i].id === id) { cd = state.countdowns[i]; break; } + } + if (cd) openCountdownModal(cd); + }); + }); + // Bind delete buttons (two-step inline confirmation) + list.querySelectorAll('[data-action="delete-cd"]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + if (btn.dataset.confirm === 'yes') { + deleteCountdown(parseInt(btn.dataset.id, 10)); + } else { + btn.dataset.confirm = 'yes'; + btn.textContent = '确认删除?'; + btn.style.background = '#E05555'; + btn.style.color = '#fff'; + setTimeout(function () { + btn.dataset.confirm = ''; + btn.textContent = '删除'; + btn.style.background = ''; + btn.style.color = ''; + }, 3000); + } + }); + }); + } + + // ─── Countdown CRUD (via /api/countdowns) ─ + function deleteCountdown(id) { + apiJson('DELETE', '/api/countdowns/' + id, null, function () { + fetchState(); + }); + } + + function _nextCountdownName() { + var used = {}; + state.countdowns.forEach(function (cd) { + var m = (cd.label || '').match(/^倒计时 (\d+)$/); + if (m) used[parseInt(m[1], 10)] = true; + }); + var n = 1; + while (used[n]) { n++; } + return '倒计时 ' + n; + } + + // ─── Countdown Modal (duration-based, no repeat) ─ + function openCountdownModal(cd) { + if (state._alarmModalOpen) return; + state._alarmModalOpen = true; + state._cdModalMode = true; + state._editingCountdown = cd || null; + + var modal = $('alarm-modal'); + var titleEl = $('alarm-modal-title'); + var nameEl = $('alarm-modal-name'); + var saveBtn = $('alarm-modal-save'); + + if (cd) { + titleEl.textContent = '编辑倒计时'; + nameEl.textContent = cd.label || ''; + saveBtn.textContent = '保存修改'; + // Parse existing duration_seconds into hours:minutes:seconds + var totalSec = cd.duration_seconds || 0; + var h = Math.floor(totalSec / 3600); + var m = Math.floor((totalSec % 3600) / 60); + var s = totalSec % 60; + $('tp-hours').textContent = String(h).padStart(2, '0'); + $('tp-minutes').textContent = String(m).padStart(2, '0'); + $('tp-seconds').textContent = String(s).padStart(2, '0'); + } else { + titleEl.textContent = '添加倒计时'; + nameEl.textContent = _nextCountdownName(); + saveBtn.textContent = '添加'; + $('tp-hours').textContent = '00'; + $('tp-minutes').textContent = '05'; + $('tp-seconds').textContent = '00'; + } + + $('alarm-modal-enabled').checked = cd ? cd.enabled !== false : true; + + // Hide alarm-only fields (once toggle + days picker) + $('alarm-once-field').style.display = 'none'; + $('alarm-days-field').style.display = 'none'; + + // Show duration label + var labelEl = document.querySelector('#alarm-modal .time-picker').previousElementSibling; + if (labelEl) labelEl.textContent = '时长'; + + modal.classList.remove('hidden'); + } + + function closeCountdownModal() { + state._alarmModalOpen = false; + state._cdModalMode = false; + state._editingCountdown = null; + $('alarm-modal').classList.add('hidden'); + } + + function saveCountdownFromModal() { + var hours = parseInt($('tp-hours').textContent, 10); + var minutes = parseInt($('tp-minutes').textContent, 10); + var seconds = parseInt($('tp-seconds').textContent, 10); + if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return; + + var totalSec = hours * 3600 + minutes * 60 + seconds; + if (totalSec <= 0) { showAlert('时长必须大于 0'); return; } + + var enabled = $('alarm-modal-enabled').checked; + var editing = state._editingCountdown; + + if (editing) { + apiJson('PUT', '/api/countdowns/' + editing.id, { + duration_seconds: totalSec, + enabled: enabled, + }, function () { + closeCountdownModal(); + fetchState(); + }); + } else { + apiJson('POST', '/api/countdowns', { + label: _nextCountdownName(), + duration_seconds: totalSec, + enabled: enabled, + }, function () { + closeCountdownModal(); + fetchState(); + }); + } + } + + // Custom notification overlay + function showCustomNotification(data) { + if (state.customNotifyActive) return; + state.customNotifyActive = true; + + var overlay = $('custom-notify-overlay'); + var iconEl = $('custom-notify-icon'); + var titleEl = $('custom-notify-title'); + var infoEl = $('custom-notify-info'); + + if (iconEl) iconEl.innerHTML = data.icon || '🔔'; + if (titleEl) titleEl.textContent = data.title || '提醒'; + if (infoEl) infoEl.textContent = data.message || ''; + if (overlay) overlay.classList.remove('hidden'); + + switchPanel('time'); + + if (data.sound !== false) { + playReminderSound(); + } + + state._customNotifyAutoDismiss = setTimeout(function () { + dismissCustomNotification(); + }, 5000); + } + + function dismissCustomNotification() { + state.customNotifyActive = false; + var overlay = $('custom-notify-overlay'); + if (overlay) overlay.classList.add('hidden'); + if (state._customNotifyAutoDismiss) { + clearTimeout(state._customNotifyAutoDismiss); + state._customNotifyAutoDismiss = null; + } + } + + var customNotifyDismissBtn = $('custom-notify-dismiss'); + if (customNotifyDismissBtn) { + customNotifyDismissBtn.addEventListener('click', dismissCustomNotification); + } + + // Dismiss button + var dismissBtn = $('alarm-dismiss'); + if (dismissBtn) { + dismissBtn.addEventListener('click', dismissAlarm); + } + + // Refresh button + var refreshBtn = $('refresh-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', function () { + location.reload(); + }); + } + + // Lock button — log out current display session + var lockBtn = $('lock-btn'); + if (lockBtn) { + lockBtn.addEventListener('click', function () { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/logout'); + xhr.onload = function () { + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + }; + xhr.send(); + }); + } + + // Fullscreen button (enter only, hides when already fullscreen) + var fullscreenBtn = $('fullscreen-btn'); + if (fullscreenBtn) { + function updateFsBtn() { + fullscreenBtn.style.display = document.fullscreenElement ? 'none' : ''; + } + document.addEventListener('fullscreenchange', updateFsBtn); + fullscreenBtn.addEventListener('click', function () { + document.documentElement.requestFullscreen(); + }); + } + + // ─── Alarm modal wiring ─────────────────── + var alarmAddBtn = $('alarm-add-btn'); + if (alarmAddBtn) { + alarmAddBtn.addEventListener('click', function () { openAlarmModal(null); }); + } + + var countdownAddBtn = $('countdown-add-btn'); + if (countdownAddBtn) { + countdownAddBtn.addEventListener('click', function () { openCountdownModal(null); }); + } + + // Modal save/cancel — route to alarm or countdown based on _cdModalMode + var modalSave = $('alarm-modal-save'); + if (modalSave) { + modalSave.addEventListener('click', function () { + if (state._cdModalMode) { + saveCountdownFromModal(); + } else { + saveAlarmFromModal(); + } + }); + } + var modalCancel = $('alarm-modal-cancel'); + if (modalCancel) { + modalCancel.addEventListener('click', function () { + if (state._cdModalMode) { + closeCountdownModal(); + } else { + closeAlarmModal(); + } + }); + } + + // Time picker buttons (mouse-only) + function setupTimePicker() { + // Up/down for hours + document.querySelectorAll('.tp-up, .tp-down').forEach(function (btn) { + btn.addEventListener('click', function () { + var target = btn.dataset.target; // 'hours' or 'minutes' + var isUp = btn.classList.contains('tp-up'); + var el = $('tp-' + target); + var val = parseInt(el.textContent, 10); + if (target === 'hours') { + val = isUp ? (val + 1) % 24 : (val + 23) % 24; + } else { + val = isUp ? (val + 1) % 60 : (val + 59) % 60; + } + el.textContent = String(val).padStart(2, '0'); + }); + }); + + // Hold-to-repeat for up/down buttons + document.querySelectorAll('.tp-btn').forEach(function (btn) { + var timer = null; + btn.addEventListener('mousedown', function () { + if (timer) clearInterval(timer); + timer = setInterval(function () { + btn.click(); + }, 180); + }); + btn.addEventListener('mouseup', function () { if (timer) { clearInterval(timer); timer = null; } }); + btn.addEventListener('mouseleave', function () { if (timer) { clearInterval(timer); timer = null; } }); + }); + } + setupTimePicker(); + + // Day picker toggle + document.querySelectorAll('#alarm-modal-days .day-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + btn.classList.toggle('active'); + }); + }); + + // Once-only toggle — hide day picker + var onceCb = $('alarm-modal-once'); + if (onceCb) { + onceCb.addEventListener('change', _updateAlarmDaysVisibility); + } + + // ─── Pomodoro controls (reuse /control endpoints) ───────────── + function pomodoroApi(action) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/pomodoro/' + action); + xhr.onload = function () { + if (xhr.status === 401) { + var res = JSON.parse(xhr.responseText || '{}'); + if (res.login_required) { + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + return; + } + } + fetchState(); + }; + xhr.send(); + } + + function fetchPomodoro() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/api/pomodoro'); + xhr.onload = function () { + if (xhr.status === 200) { + try { + state.pomodoro = JSON.parse(xhr.responseText); + renderPomodoro(); + renderPomodoroCard(); + } catch (_) {} + } + }; + xhr.send(); + } + + // Card pomodoro buttons (in sidebar card, not the panel) + var cardBtns = $('pomo-sidebar-btns'); + if (cardBtns) { + ['start', 'pause', 'skip', 'stop'].forEach(function (action) { + var btn = $('pomo-card-' + action); + if (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + pomodoroApi(action); + }); + } + }); + } + + // ─── Rendering ──────────────────────────── + function renderAll() { + renderTimeCard(); + renderWeatherCard(); + renderCalendarCard(); + renderAlarmCard(); + renderCountdownCard(); + renderPomodoroCard(); + renderTimePanel(); + renderWeatherPanel(); + renderCalendarPanel(); + renderAlarmPanel(); + renderCountdownPanel(); + renderPomodoro(); + } + + function renderTimeCard() { + var now = new Date(); + var h = String(now.getHours()).padStart(2, '0'); + var m = String(now.getMinutes()).padStart(2, '0'); + $('time-value').textContent = h + ':' + m; + } + + function renderWeatherCard() { + var w = state.weather || {}; + if (w.temp !== undefined) { + $('weather-temp').textContent = w.temp + '°'; + } + var iconEl = $('weather-card-icon'); + if (w.icon && iconEl) { + iconEl.src = '/static/icons/' + w.icon + '.svg'; + iconEl.style.display = ''; + iconEl.alt = w.description || ''; + } else if (iconEl) { + iconEl.style.display = 'none'; + } + } + + function renderCalendarCard() { + var now = new Date(); + var nowStr = formatDateTimeLocal(now); + var upcoming = null; + for (var i = 0; i < state.events.length; i++) { + var ev = state.events[i]; + var dtend = ev.dtend || ev.dtstart; + if (dtend >= nowStr) { + upcoming = ev; + break; + } + } + if (upcoming) { + var text = upcoming.summary || '日程'; + var startTime = upcoming.is_all_day ? '全天' : formatTime(upcoming.dtstart); + var endTime = upcoming.is_all_day ? '' : '-' + formatTime(upcoming.dtend || upcoming.dtstart); + $('calendar-next').textContent = text + ' ' + startTime + endTime; + } else { + $('calendar-next').textContent = '无日程'; + } + } + + function formatTime(dtStr) { + if (!dtStr || dtStr.length < 13) return ''; + return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13); + } + + function formatDateTimeLocal(d) { + return d.getFullYear() + + String(d.getMonth() + 1).padStart(2, '0') + + String(d.getDate()).padStart(2, '0') + 'T' + + String(d.getHours()).padStart(2, '0') + + String(d.getMinutes()).padStart(2, '0') + + String(d.getSeconds()).padStart(2, '0'); + } + + function renderAlarmCard() { + var now = new Date(); + var currentMinutes = now.getHours() * 60 + now.getMinutes(); + var todayWeekday = String(now.getDay() === 0 ? 7 : now.getDay()); + var upcoming = null; + var upcomingDiff = null; + + state.alarms.forEach(function (a) { + if (!a.enabled) return; + var days = (a.days || '').split(','); + if (days.indexOf(todayWeekday) === -1) return; + var parts = a.time.split(':'); + var alarmMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var diff = alarmMinutes - currentMinutes; + if (diff > 0) { + if (upcomingDiff === null || diff < upcomingDiff) { + upcoming = a; + upcomingDiff = diff; + } + } + }); + + // If no upcoming alarm today, look for the earliest alarm on future days + if (!upcoming) { + state.alarms.forEach(function (a) { + if (!a.enabled) return; + var days = (a.days || '').split(','); + var parts = a.time.split(':'); + var alarmMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var minDayDiff = null; + days.forEach(function (d) { + var dayNum = parseInt(d, 10); + var todayNum = parseInt(todayWeekday, 10); + var dayDiff = dayNum - todayNum; + if (dayDiff <= 0) dayDiff += 7; + if (minDayDiff === null || dayDiff < minDayDiff) { + minDayDiff = dayDiff; + } + }); + if (minDayDiff !== null) { + var totalDiff = (minDayDiff * 24 * 60) + alarmMinutes - currentMinutes; + if (upcomingDiff === null || totalDiff < upcomingDiff) { + upcoming = a; + upcomingDiff = totalDiff; + } + } + }); + } + + if (upcoming) { + $('alarm-next').textContent = upcoming.time; + } else { + $('alarm-next').textContent = '无闹钟'; + } + } + + function renderPomodoroCard() { + var p = state.pomodoro || {}; + var el = $('pomodoro-value'); + var btnStart = $('pomo-card-start'); + var btnPause = $('pomo-card-pause'); + var btnSkip = $('pomo-card-skip'); + var btnStop = $('pomo-card-stop'); + if (!el || !btnStart) return; + + // Time display + if (!p.enabled) { + el.textContent = '未启动'; + } else { + var remaining = _calcPomodoroRemaining(p); + el.textContent = String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + + String(remaining % 60).padStart(2, '0'); + } + + // Button visibility + var isPomodoroPanel = state.activePanel === 'pomodoro'; + var showTime = !isPomodoroPanel; + var showBtns = isPomodoroPanel; + + el.style.display = showTime ? '' : 'none'; + if (btnStart) btnStart.style.display = 'none'; + if (btnPause) btnPause.style.display = 'none'; + if (btnSkip) btnSkip.style.display = 'none'; + if (btnStop) btnStop.style.display = 'none'; + + if (showBtns && p.enabled) { + if (p.running) { + // Running → 只显示暂停 + if (btnPause) btnPause.style.display = ''; + } else { + // Paused → 显示继续/跳过/停止 + if (btnStart) { btnStart.textContent = '继续'; btnStart.style.display = ''; } + if (btnSkip) btnSkip.style.display = ''; + if (btnStop) btnStop.style.display = ''; + } + } else if (showBtns && !p.enabled) { + // Stopped → 只显示开始 + if (btnStart) { btnStart.textContent = '开始'; btnStart.style.display = ''; } + } + // When NOT on pomodoro panel: show nothing for buttons (already hidden above) + } + + function _calcPomodoroRemaining(p) { + if (!p || !p.enabled) return 0; + if (!p.running) return p.remaining_seconds || 0; + var end = p.end_time; + if (!end) return p.remaining_seconds || 0; + try { + // end_time is a Unix timestamp string (e.g. "1785285282.447") + var remaining = Math.floor(parseFloat(end) - Date.now() / 1000); + return Math.max(0, remaining); + } catch (_) { + return p.remaining_seconds || 0; + } + } + + function renderPomodoro() { + var p = state.pomodoro || {}; + var phaseEl = $('pomodoro-phase'); + var timerEl = $('pomodoro-timer'); + var cyclesEl = $('pomodoro-cycles'); + var nextBreakEl = $('pomodoro-next-break'); + if (!phaseEl || !timerEl || !cyclesEl) return; + if (!p.enabled) { + phaseEl.textContent = '未启动'; + timerEl.textContent = '--:--'; + cyclesEl.textContent = ''; + if (nextBreakEl) nextBreakEl.textContent = ''; + return; + } + phaseEl.textContent = p.phase === 'work' ? (p.work_label || '工作中') : (p.break_label || '休息中'); + // Show/hide card buttons based on display_controls_enabled + var cardBtns = $('pomo-sidebar-btns'); + if (cardBtns) { + cardBtns.style.display = (p.display_controls_enabled !== false) ? '' : 'none'; + } + var remaining = _calcPomodoroRemaining(p); + var m = String(Math.floor(remaining / 60)).padStart(2, '0'); + var s = String(remaining % 60).padStart(2, '0'); + timerEl.textContent = m + ':' + s; + cyclesEl.textContent = '已完成 ' + (p.cycles || 0) + ' 组'; + if (nextBreakEl) { + nextBreakEl.textContent = '下次: ' + (p.next_break_type === 'long' ? '长休息' : '短休息'); + } + } + + function renderTimePanel() { + var now = new Date(); + $('detailed-time').textContent = + String(now.getHours()).padStart(2, '0') + ':' + + String(now.getMinutes()).padStart(2, '0') + ':' + + String(now.getSeconds()).padStart(2, '0'); + var days = ['日', '一', '二', '三', '四', '五', '六']; + $('date-str').textContent = + now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日 星期' + days[now.getDay()]; + + } + + function renderWeatherPanel() { + var w = state.weather || {}; + if (w.temp === undefined) { + $('weather-temp-big').textContent = '--°'; + $('weather-desc').textContent = w.error || '无数据'; + $('weather-details').innerHTML = ''; + return; + } + $('weather-temp-big').textContent = w.temp + '°C'; + $('weather-desc').textContent = w.description || ''; + var iconEl = $('weather-icon'); + if (w.icon && iconEl) { + iconEl.src = '/static/icons/' + w.icon + '.svg'; + iconEl.style.display = 'inline'; + iconEl.alt = w.description || ''; + } else if (iconEl) { + iconEl.style.display = 'none'; + } + var details = ''; + if (w.feels_like !== undefined) details += '
体感
' + w.feels_like + '°C
'; + if (w.humidity !== undefined) details += '
湿度
' + w.humidity + '%
'; + if (w.wind_speed !== undefined) details += '
风速
' + w.wind_speed + ' m/s
'; + if (w.pressure !== undefined) details += '
气压
' + w.pressure + ' hPa
'; + if (w.wind_dir) details += '
风向
' + w.wind_dir + (w.wind_scale ? ' ' + w.wind_scale + '级' : '') + '
'; + if (w.precip !== undefined && w.precip != '0') details += '
降水量
' + w.precip + ' mm
'; + if (w.visibility) details += '
能见度
' + w.visibility + ' km
'; + if (w.dew) details += '
露点温度
' + w.dew + '°C
'; + $('weather-details').innerHTML = details; + + // Render hourly forecast + _renderHourly(); + + // Render minutely + warnings + _renderMinutely(); + _renderWarnings(); + + // Render update schedule + _renderWeatherUpdateInfo(); + } + + function _renderHourly() { + var list = $('hourly-list'); + if (!list) return; + var hourly = state.hourly || []; + if (hourly.length === 0) { + list.innerHTML = '
暂无逐小时数据
'; + return; + } + var html = ''; + var nowHour = new Date().getHours(); + var nowMin = new Date().getMinutes(); + hourly.forEach(function (h) { + var hh = parseInt(h.time.split(':')[0], 10); + var hm = parseInt(h.time.split(':')[1], 10); + var isNow = (hh === nowHour && hm <= nowMin + 5); + var dayLabel = h.day || ''; + html += '
' + + '
' + (dayLabel === '明日' ? '明' : '') + h.time + '
' + + '
' + _qwIcon(h.icon) + '
' + + '
' + h.temp + '°
' + + '
' + (parseInt(h.pop, 10) > 0 ? h.pop + '%' : '') + '
' + + '
'; + }); + list.innerHTML = html; + } + + function _qwIcon(code) { + if (code) return ''; + return ''; + } + + function _renderMinutely() { + var section = $('minutely-section'); + var content = $('minutely-content'); + if (!section || !content) return; + + // Always show the section (title + refresh button visible) + section.style.display = ''; + + var m = state.minutely; + if (!m) { + content.innerHTML = '
点击「刷新」获取分钟级降水预报
'; + return; + } + if (m.error) { + if (m.cooldown) { + content.innerHTML = '
' + m.error + '
'; + } else { + content.innerHTML = '
' + m.error + '
'; + } + return; + } + + var list = m.minutely || []; + var summary = m.summary || ''; + var html = ''; + if (summary) html += '
' + summary + '
'; + if (list.length > 0) { + html += '
'; + list.forEach(function (item) { + var pct = Math.min(parseFloat(item.precip) || 0, 5) / 5 * 100; + html += '
' + (item.fxTime ? item.fxTime.substring(11, 16) : '') + '
'; + }); + html += '
'; + } + content.innerHTML = html; + } + + function _renderWarnings() { + var section = $('warning-section'); + var content = $('warning-content'); + if (!section || !content) return; + + // Always show the section (title + refresh button visible) + section.style.display = ''; + + var w = state.warnings; + if (!w) { + content.innerHTML = '
点击「刷新」获取天气预警
'; + return; + } + + var html = ''; + if (w.unavailable) { + html = '
天气预警为付费功能,当前订阅不可用
'; + content.innerHTML = html; + return; + } + if (w.error && !w.cooldown) { + html = '
' + w.error + '
'; + content.innerHTML = html; + return; + } + if (w.zero_result || !w.warnings || w.warnings.length === 0) { + html = '
暂无预警
'; + content.innerHTML = html; + return; + } + // Per QWeather docs: do NOT enumerate severity/type values — just output + // raw alert data as-is, since these values can change without notice. + w.warnings.forEach(function (item) { + var fields = ''; + for (var key in item) { + if (item.hasOwnProperty(key) && item[key]) { + fields += '
' + key + ': ' + item[key] + '
'; + } + } + html += '
' + + '
' + (item.title || item.type || '预警') + '
' + + fields + + '
'; + }); + content.innerHTML = html; + } + + function _renderWeatherUpdateInfo() { + var s = state.update_schedule || {}; + var wUpdated = s.weather_updated_at || ''; + + // Compute next update times on the frontend from current local time + var now = new Date(); + var m = now.getMinutes(); + + // Next weather: next :00 or :30 + var nextW = new Date(now); + if (m < 30) { nextW.setMinutes(30, 0, 0); } + else { nextW.setHours(now.getHours() + 1, 0, 0, 0); } + + // Next hourly: next :01 + var nextH = new Date(now); + if (m < 1) { nextH.setMinutes(1, 0, 0); } + else { nextH.setHours(now.getHours() + 1, 1, 0, 0); } + + var fmt = function (d) { + if (!d) return '--'; + // Server sends UTC datetime — parse as UTC, display in local timezone + var s = String(d).replace('T', ' '); + // Safari needs 'T' not space for ISO parsing + var utcDate = new Date(String(d).replace(' ', 'T') + 'Z'); + if (!isNaN(utcDate.getTime())) { + var pad = function (n) { return String(n).padStart(2, '0'); }; + return utcDate.getFullYear() + '-' + pad(utcDate.getMonth() + 1) + '-' + pad(utcDate.getDate()) + + ' ' + pad(utcDate.getHours()) + ':' + pad(utcDate.getMinutes()); + } + // Fallback to raw string + return s.substring(0, 16); + }; + + var fmtDate = function (d) { + return String(d.getFullYear()) + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + ' ' + + String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0'); + }; + + var html = '
' + + '
实况更新: ' + fmt(wUpdated) + + ' 下次: ' + fmtDate(nextW) + '
' + + '
逐时下次: ' + fmtDate(nextH) + '
' + + '
'; + + // Append after hourly list + var list = $('hourly-list'); + if (list) { + var existing = list.parentNode.querySelector('.update-info'); + if (existing) existing.remove(); + list.insertAdjacentHTML('afterend', html); + } + } + + // ─── State update from fetch ────────────── + function renderCalendarPanel() { + var list = $('event-list'); + if (state.events.length === 0) { + list.innerHTML = '
暂无日程
'; + return; + } + var groups = {}; + state.events.forEach(function (ev) { + var dk = ev.dtstart ? ev.dtstart.substring(0, 8) : 'unknown'; + if (!groups[dk]) groups[dk] = []; + groups[dk].push(ev); + }); + var todayKey = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + var t = new Date(); t.setDate(t.getDate() + 1); + var tomorrowKey = t.toISOString().slice(0, 10).replace(/-/g, ''); + var html = ''; + Object.keys(groups).sort().forEach(function (dk) { + var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' : + dk.substring(0, 4) + '/' + dk.substring(4, 6) + '/' + dk.substring(6, 8); + html += '
' + label + '
'; + groups[dk].forEach(function (ev) { + var startTime = ev.is_all_day ? '全天' : (ev.dtstart ? ev.dtstart.substring(9, 11) + ':' + ev.dtstart.substring(11, 13) : '--:--'); + var endTime = ev.is_all_day ? '' : (ev.dtend ? ' - ' + ev.dtend.substring(9, 11) + ':' + ev.dtend.substring(11, 13) : ''); + var timeStr = startTime + endTime; + var color = '#4A90D9'; + if (state.groups) { + var g = state.groups.find(function (x) { return x.id === ev.group_id; }); + if (g) color = g.color || color; + } + html += '
' + + '
' + timeStr + '
' + + '
' + _esc(ev.summary || '无标题') + '
' + + (ev.location ? '
' + _esc(ev.location) + '
' : '') + '
'; + }); + }); + list.innerHTML = html; + } + + function renderAlarmPanel() { + var list = $('alarm-list'); + var dayNames = ['', '一', '二', '三', '四', '五', '六', '日']; + if (state.alarms.length === 0) { + list.innerHTML = '
暂无闹钟
'; + return; + } + var html = ''; + state.alarms.forEach(function (a) { + var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' '); + html += '
' + + '
' + + '
' + a.time + '
' + + (a.label ? '
' + a.label + '
' : '') + + '
' + days + '
' + + '
' + + '' + + '' + + '
'; + }); + list.innerHTML = html; + + // Bind edit buttons + list.querySelectorAll('[data-action="edit-alarm"]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var id = parseInt(btn.dataset.id, 10); + var alarm = null; + for (var i = 0; i < state.alarms.length; i++) { + if (state.alarms[i].id === id) { alarm = state.alarms[i]; break; } + } + if (alarm) openAlarmModal(alarm); + }); + }); + // Bind delete buttons (two-step inline confirmation) + list.querySelectorAll('[data-action="delete-alarm"]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + if (btn.dataset.confirm === 'yes') { + deleteAlarm(parseInt(btn.dataset.id, 10)); + } else { + btn.dataset.confirm = 'yes'; + btn.textContent = '确认删除?'; + btn.style.background = '#E05555'; + btn.style.color = '#fff'; + var _reset = function () { setTimeout(function () { + btn.dataset.confirm = ''; + btn.textContent = '删除'; + btn.style.background = ''; + btn.style.color = ''; + }, 3000); }; + _reset(); + } + }); + }); + } + + // ─── Simple toast popup ────────────────── + function showToast(msg, duration) { + duration = duration || 2500; + var el = document.createElement('div'); + el.className = 'display-toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(function () { el.remove(); }, duration); + } + + // ─── Minutely / Warning refresh buttons ─── + $('minutely-refresh-btn').addEventListener('click', function () { + apiJson('POST', '/api/weather/minutely', null, function (data) { + if (data.error) { + var remaining = data.cooldown; + var msg = data.error; + if (remaining) msg += '(' + remaining + '秒)'; + showToast(msg); + } else { + state.minutely = data; + _renderMinutely(); + } + }); + }); + + $('warning-refresh-btn').addEventListener('click', function () { + apiJson('POST', '/api/weather/warnings', null, function (data) { + if (data.error) { + var remaining = data.cooldown; + var msg = data.error; + if (remaining) msg += '(' + remaining + '秒)'; + showToast(msg); + } else { + state.warnings = data; + _renderWarnings(); + } + }); + }); + + // ─── Clock tick ─────────────────────────── + setInterval(function () { + renderTimeCard(); + renderCountdownCard(); + renderPomodoroCard(); + if (state.activePanel === 'time') renderTimePanel(); + if (state.activePanel === 'pomodoro') renderPomodoro(); + if (state.activePanel === 'countdown') renderCountdownPanel(); + // Refresh update schedule info every 10s to keep next-update times current + if ((state._tickCount || 0) % 10 === 0 && state.activePanel === 'weather') { + _renderWeatherUpdateInfo(); + } + // When weather panel is open, check if hourly needs refresh (past :01, stale) + if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') { + var now = new Date(); + var min = now.getMinutes(); + var s = state.update_schedule || {}; + // If it's past :05 and the hourly was likely last fetched before this hour + if (min > 5) { + apiJson('GET', '/api/weather/hourly', null, function (data) { + if (data && data.length > 0) { + state.hourly = data; + _renderHourly(); + } + }); + } + } + state._tickCount = (state._tickCount || 0) + 1; + }, 1000); + + // ─── Init ───────────────────────────────── + fetchState(); + connectSSE(); + switchPanel('time'); + +})(); diff --git a/frontend/static/js/passkey_bridge.js b/frontend/static/js/passkey_bridge.js new file mode 100644 index 0000000..5deeb7f --- /dev/null +++ b/frontend/static/js/passkey_bridge.js @@ -0,0 +1,145 @@ +/* Passkey 中间件:统一所有 WebAuthn 调用(登录/注册/二次验证/管理员代配)。 + * + * 根据 Agent 判定执行路径: + * - 浏览器 / 小窗(UA 不含 "CalDisplayApp" 且未注入标志)→ 直接调用 + * navigator.credentials(标准 WebAuthn,Safari 小窗环境原生支持 Passkey) + * - iOS 套壳 App(注入 window.__CAL_DISPLAY_APP__ 或 UA 含 "CalDisplayApp") + * → 把 {op, options} 存入服务端一次性 stash,跳转 /passkey 小窗页面; + * App 拦截导航后用 ASWebAuthenticationSession 打开该页面,页面内完成 + * WebAuthn 仪式,经 dashboard://passkey 回调由 App 回传结果。 + * + * 所有返回的 credential 均为纯 base64url JSON(与浏览器 WebAuthn 结构一致), + * 调用方无需再做 ArrayBuffer 转换。 + */ +(function (global) { + 'use strict'; + + // iOS 套壳 App 判定:App 注入的全局标志优先,UA 标记兜底 + var IS_IOS_APP = false; + try { IS_IOS_APP = window.__CAL_DISPLAY_APP__ === true; } catch (e) {} + if (!IS_IOS_APP) { IS_IOS_APP = /CalDisplayApp/i.test(navigator.userAgent || ''); } + var _pending = null; // {resolve, reject} + + // ── base64url <-> ArrayBuffer ── + function b64ToBuf(b64) { + var base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) base64 += '='; + var binary = atob(base64); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } + function bufToB64(buf) { + var bytes = new Uint8Array(buf); + var binary = ''; + for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + // ── options 准备(浏览器模式:base64url → ArrayBuffer)── + function prepareCreation(options) { + var o = JSON.parse(JSON.stringify(options)); + o.challenge = b64ToBuf(o.challenge); + if (o.user && o.user.id) o.user.id = b64ToBuf(o.user.id); + if (o.excludeCredentials) { + o.excludeCredentials = o.excludeCredentials.map(function (c) { + return { id: b64ToBuf(c.id), type: c.type }; + }); + } + return o; + } + function prepareAssertion(options) { + var o = JSON.parse(JSON.stringify(options)); + o.challenge = b64ToBuf(o.challenge); + if (o.allowCredentials) { + o.allowCredentials = o.allowCredentials.map(function (c) { + return { id: b64ToBuf(c.id), type: c.type }; + }); + } + return o; + } + + // ── 浏览器直接 WebAuthn ── + function webAuthnCreation(options) { + return navigator.credentials.create({ publicKey: prepareCreation(options) }).then(function (cred) { + return { + id: cred.id, + type: cred.type, + rawId: bufToB64(cred.rawId), + response: { + clientDataJSON: bufToB64(cred.response.clientDataJSON), + attestationObject: bufToB64(cred.response.attestationObject), + transports: cred.response.getTransports ? cred.response.getTransports() : [] + } + }; + }); + } + function webAuthnAssertion(options) { + return navigator.credentials.get({ publicKey: prepareAssertion(options) }).then(function (cred) { + return { + id: cred.id, + type: cred.type, + rawId: bufToB64(cred.rawId), + response: { + authenticatorData: bufToB64(cred.response.authenticatorData), + clientDataJSON: bufToB64(cred.response.clientDataJSON), + signature: bufToB64(cred.response.signature), + userHandle: cred.response.userHandle ? bufToB64(cred.response.userHandle) : null + } + }; + }); + } + + // ── iOS App:先把参数存入服务端一次性 stash,再跳转小窗页面 ── + // 小窗(ASWebAuthenticationSession)是独立的 ephemeral 会话,读不到主容器 + // 的 sessionStorage,因此参数通过服务端短时暂存传递。 + + function iosDispatch(op, options) { + return fetch('/api/passkey/stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op: op, options: options }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.sid) throw new Error(d.error || 'Passkey 参数暂存失败'); + var base = location.origin || ('https://' + location.host); + window.location.href = base + '/passkey?op=' + encodeURIComponent(op) + '&sid=' + encodeURIComponent(d.sid); + return new Promise(function (resolve, reject) { + _pending = { resolve: resolve, reject: reject }; + // 兜底:小窗未完成/被取消时避免 promise 悬挂 + setTimeout(function () { + if (_pending) { + _pending = null; + reject(new Error('Passkey 认证超时或已取消')); + } + }, 120000); + }); + }); + } + + // ── 供原生层(evaluateJavaScript)调用的桥接方法 ── + function __iosComplete(credentialJson) { + var p = _pending; + _pending = null; + if (p) p.resolve(credentialJson); + } + function __iosCancel(message) { + var p = _pending; + _pending = null; + if (p) p.reject(new Error(message || 'Passkey 认证已取消')); + } + + // ── 对外统一入口 ── + global.PasskeyBridge = { + creation: function (options) { + return IS_IOS_APP ? iosDispatch('creation', options) : webAuthnCreation(options); + }, + assertion: function (options) { + return IS_IOS_APP ? iosDispatch('assertion', options) : webAuthnAssertion(options); + }, + isIOSApp: function () { return IS_IOS_APP; }, + __iosComplete: __iosComplete, + __iosCancel: __iosCancel + }; +})(window); diff --git a/frontend/static/js/theme.js b/frontend/static/js/theme.js new file mode 100644 index 0000000..9e703b4 --- /dev/null +++ b/frontend/static/js/theme.js @@ -0,0 +1,64 @@ +/* 主题系统:账户独立主题(dark/light/auto)。 + * + * - 后端 GET /api/theme 返回 {mode, effective};auto 已解析为 dark/light + * (按用户经纬度+时区计算日出日落)。 + * - 应用方式:html 元素加 .theme-light class(CSS 变量覆盖浅色)。 + * - 切换动画:切换瞬间给 html 加 .theme-anim,触发全局 CSS transition + * 平滑渐变,平时无全局 transition(不影响交互反馈)。 + * - auto 模式每 5 分钟轮询一次(日落日出边界切换);显示端还会在收到 + * SSE theme_changed 时立即刷新。 + */ +(function (global) { + 'use strict'; + + var current = 'dark'; + var pollTimer = null; + + function apply(effective) { + if (!effective) return; + if (effective === current) return; + current = effective; + var root = document.documentElement; + root.classList.add('theme-anim'); + root.classList.toggle('theme-light', effective === 'light'); + // 过渡结束后移除动画 class,恢复常规交互反馈 + setTimeout(function () { root.classList.remove('theme-anim'); }, 600); + } + + function fetchTheme(cb) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/api/theme'); + xhr.onload = function () { + if (xhr.status === 200) { + var d = {}; + try { d = JSON.parse(xhr.responseText || '{}'); } catch (e) {} + if (d.effective) apply(d.effective); + if (cb) cb(d); + } + }; + xhr.onerror = function () { /* 网络失败保持当前主题 */ }; + xhr.send(); + } + + function initTheme() { + fetchTheme(); + if (!pollTimer) { + // auto 模式日落/日出切换:每 5 分钟刷新一次 + pollTimer = setInterval(fetchTheme, 5 * 60 * 1000); + } + } + + global.ThemeBridge = { + apply: apply, + fetch: fetchTheme, + init: initTheme, + getCurrent: function () { return current; } + }; + + // 页面加载即应用主题 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initTheme); + } else { + initTheme(); + } +})(window); diff --git a/frontend/static/ringtones/null b/frontend/static/ringtones/null new file mode 100644 index 0000000..e69de29 diff --git a/frontend/templates/control.html b/frontend/templates/control.html new file mode 100644 index 0000000..9077f42 --- /dev/null +++ b/frontend/templates/control.html @@ -0,0 +1,395 @@ + + + + + +控制面板 - Cal Display + + + + +
+
+

Cal Display 控制面板

+ +
+ +
+ +
+ +
+

日历分组

+
+
+ + + +
+
+ + +
+

上传 ICS 文件

+
+ + + + +
+
+ + +
+

订阅 ICS 链接

+
+ + + + +
+
+ + +
+

提醒设置

+
+ + + + + +
+
+ + +
+

手动添加日程

+
+ + + + + + +
+
+ + +
+

分组日程管理

+
+ + +
+
+
+
+ + +
+ +
+

JWT 密钥管理(和风天气)

+

+ 生成本地 Ed25519 密钥对,将公钥复制到 + 和风天气控制台 + → 凭据管理 → 添加凭据。 +

+
+ + +
+ + +
+ + +
+

城市搜索

+
+ + +
+
+
+ + +
+

和风天气配置

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ ⚠️ 共享域名已弃用!登录 和风控制台 + → 产品管理 → 你的项目 → API Host,复制完整域名填入此处 +
+
+
+ +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+ + +
+

当前天气预览

+
加载中...
+
+
+ + +
+ +
+

闹钟列表

+
+
+ + + + +
+ + + + + + + +
+ +
+
+ + +
+

自定义铃声

+
+ + +
+
+
+
+ + +
+
+

番茄钟设置

+
+
+ + + + + + + + +
+
+ + + + +
+
+
+ + +
+
+

用户管理

+

+ 仅管理员可用。注册/编辑/删除用户均为高危操作,每次操作前需二次验证管理员身份。 + 新注册用户的全部数据相互隔离,且不拥有用户管理权限。 +

+
+ + + +
+
+
+
+
+ + +
+
+

主题模式

+

+ 跟随账户独立设置,显示端实时同步生效。 + 自动 模式按你天气设置中的城市经纬度计算日出日落,白天浅色、夜晚深色。 +

+
+ + +
+
+ +
+

显示端模式

+ + +
+
+
+ +
+ + + + +
+
+ +
+ + + +
+
+
+ +
+
+
+
+

时区设置

+
+ 所有本地时间将根据此设置显示。使用 IANA 时区名称 或 UTC+X 格式。 +
+
+ + + +
+
+
+

手动推送提醒

+
+ + + + +
+
+
+
+

双因素认证 (MFA)

+
加载中...
+ + +
+

🔑 Passkey (设备密钥)

+

+ 使用指纹、面容或安全密钥快速登录。 +

+ + +
+
+ + +
+

📱 TOTP (身份验证器)

+

+ 使用 Google Authenticator、Microsoft Authenticator 等应用。 +

+ + + +
+
+ + + +
+
+

退出登录

+ +
+
+
+
+ + + + + + diff --git a/frontend/templates/display.html b/frontend/templates/display.html new file mode 100644 index 0000000..b35f2c6 --- /dev/null +++ b/frontend/templates/display.html @@ -0,0 +1,248 @@ + + + + + +Cal Display + + + + +
+ + + + +
+
+ + + +
+
+

时间

+
--:--:--
+
+
+
+

天气

+
+ --° + + +
+
+
+
逐小时预报
+
+
+ + + + +
+
+

日程

+
+
+
+

闹钟

+
+ +
+
+

倒计时

+
+ +
+
+
+
+
+

番茄钟

+
+
+
工作中
+
--:--
+
已完成 0 组
+
下次: 短休息
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+ + + + + diff --git a/frontend/templates/display_custom.html b/frontend/templates/display_custom.html new file mode 100644 index 0000000..4fca2bf --- /dev/null +++ b/frontend/templates/display_custom.html @@ -0,0 +1,172 @@ + + + + + +Cal Display - Custom + + + + +
+ {{ custom_html | safe }} +
+ + + + + + + diff --git a/frontend/templates/login.html b/frontend/templates/login.html new file mode 100644 index 0000000..bd6255b --- /dev/null +++ b/frontend/templates/login.html @@ -0,0 +1,434 @@ + + + + + +登录 - Cal Display + + + + + + + + + + + diff --git a/frontend/templates/login_control.html b/frontend/templates/login_control.html new file mode 100644 index 0000000..033a9da --- /dev/null +++ b/frontend/templates/login_control.html @@ -0,0 +1,401 @@ + + + + + +登录 - Cal Display 控制端 + + + + + + + + + + + diff --git a/frontend/templates/mfa_setup.html b/frontend/templates/mfa_setup.html new file mode 100644 index 0000000..6fbfa87 --- /dev/null +++ b/frontend/templates/mfa_setup.html @@ -0,0 +1,456 @@ + + + + + +安全设置 - Cal Display + + + + + + +
+

🔐 安全设置

+

请至少配置一种双重认证方式以保护你的设备

+ + +
+
+

🔑 Passkey (生物/设备密钥)

+ 未配置 +
+
+

使用设备内置的安全模块(指纹、面容、PIN 或安全密钥)快速登录。

+ +
+
+
+ + +
+
+

📱 TOTP (身份验证器应用)

+ 未配置 +
+
+

使用 Google Authenticator、Microsoft Authenticator 等应用扫描二维码。

+ + +
+
+ + +
+

⚠️ 恢复码(请妥善保管)

+

每个恢复码仅可使用一次。建议截图或打印保存。

+
+ +
+ + +
+ +
+
+
+ + + + diff --git a/frontend/templates/passkey.html b/frontend/templates/passkey.html new file mode 100644 index 0000000..62341e9 --- /dev/null +++ b/frontend/templates/passkey.html @@ -0,0 +1,85 @@ + + + + + +Passkey 验证 + + + + +
+
🔑
+

Passkey 验证

+

正在准备安全验证…

+
+ + + + diff --git a/frontend/templates/register_request.html b/frontend/templates/register_request.html new file mode 100644 index 0000000..a08ede2 --- /dev/null +++ b/frontend/templates/register_request.html @@ -0,0 +1,151 @@ + + + + + +申请账号 - Cal Display + + + + + +
+

📋 申请账号

+

填写你的称呼,提交后等待管理员确认。管理员确认后你会收到一个临时验证码,用它登录并配置你自己的登录方式(Passkey 或 TOTP)。

+ + +
+ ← 返回登录 +
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..826c8e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +flask>=3.0 +flask-cors>=4.0 +apscheduler>=3.10 +requests>=2.31 +icalendar>=5.0 +recurring-ical-events>=3.0 +pytz>=2024.1 +PyJWT>=2.8 +cryptography>=42.0 +webauthn>=2.0 +pyotp>=2.9 +qrcode[pil]>=7.4 diff --git a/run.py b/run.py new file mode 100644 index 0000000..58778d3 --- /dev/null +++ b/run.py @@ -0,0 +1,40 @@ +"""Production entry point for the Calendar Display application.""" +import os +import sys + +os.chdir(os.path.dirname(os.path.abspath(__file__))) + +from backend.app import create_app +from backend.scheduler import start_scheduler, stop_scheduler +from backend.models import _EMITTED_PASSWORDS, get_auth_config + +# Use environment variable to toggle debug, default False for production +DEBUG = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes") + +db_path = os.environ.get("FLASK_DB_PATH", os.path.join(os.path.dirname(__file__), "data.db")) +app = create_app(db_path=db_path) + +if __name__ == "__main__": + # In debug mode, scheduler runs only in the reloader child process + is_reloader_child = os.environ.get("WERKZEUG_RUN_MAIN") == "true" + + if not DEBUG or is_reloader_child: + start_scheduler(app) + + # Print temp password at startup + with app.app_context(): + if not _EMITTED_PASSWORDS: + cfg = get_auth_config() + pw = cfg.get("temp_password", "") + if pw and cfg.get("must_configure_mfa"): + print(f"\n{'='*50}", file=sys.stderr) + print(f" 🔐 临时密码: {pw}", file=sys.stderr) + print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr) + print(f"{'='*50}\n", file=sys.stderr) + + try: + bind_host = os.environ.get("FLASK_BIND", "127.0.0.1") + app.run(host=bind_host, port=5001, debug=DEBUG, threaded=True) + finally: + if not DEBUG or is_reloader_child: + stop_scheduler() \ No newline at end of file diff --git a/start.py b/start.py new file mode 100644 index 0000000..c9db523 --- /dev/null +++ b/start.py @@ -0,0 +1,23 @@ +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from backend.app import create_app +from backend.scheduler import start_scheduler +from backend.models import _EMITTED_PASSWORDS, get_auth_config + +db_path = os.environ.get("FLASK_DB_PATH", os.path.join(os.path.dirname(__file__), "data.db")) +app = create_app(db_path=db_path) +start_scheduler(app) + +# Print temp password if it wasn't already emitted during DB init +with app.app_context(): + if not _EMITTED_PASSWORDS: + cfg = get_auth_config() + pw = cfg.get("temp_password", "") + if pw and cfg.get("must_configure_mfa"): + print(f"\n{'='*50}", file=sys.stderr) + print(f" 🔐 临时密码 (重启后仍有效): {pw}", file=sys.stderr) + print(f" ⚠️ 请使用此密码登录并配置双因素认证", file=sys.stderr) + print(f"{'='*50}\n", file=sys.stderr) + +bind_host = os.environ.get("FLASK_BIND", "127.0.0.1") +app.run(host=bind_host, port=5000, debug=False, threaded=True) \ No newline at end of file diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py new file mode 100644 index 0000000..068c2f6 --- /dev/null +++ b/tests/test_multi_user.py @@ -0,0 +1,716 @@ +"""Multi-user system test suite. + +Run: python3 tests/test_multi_user.py +Covers: Root bootstrap, legacy/new-DB migration, admin registration (both modes), +temp-account lifecycle, data isolation, admin-only enforcement, re-auth +(TOTP + passkey fallback), deletion, kick-all, SSE scoping, rate limits, CSRF. +""" +import base64 +import io +import json +import os +import shutil +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("FLASK_SECRET_KEY", "test-secret") + +import pyotp + +from backend.app import create_app +from backend.models import ( + init_db, _EMITTED_PASSWORDS, get_auth_config, get_temp_password, + get_users, get_user, is_temp_account, get_temp_totp_secret, + get_active_user_ids, +) +from backend.events import EventBus + +PASS, FAIL = 0, 0 + + +def _groups_for(db): + import sqlite3 + conn = sqlite3.connect(db) + n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0] + conn.close() + return n + + +def _count(db, table, user_id): + import sqlite3 + conn = sqlite3.connect(db) + n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0] + conn.close() + return n + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ✅ {name}") + else: + FAIL += 1 + print(f" ❌ {name} {detail}") + + +def fresh_app(tmp): + db = os.path.join(tmp, "fresh.db") + app = create_app(db_path=db) + return app, db + + +def login_temp(app, code, view="control"): + return app.test_client().post("/api/auth/login", json={"code": code, "view": view}) + + +def setup_totp(client, secret=None): + """Complete the temp→TOTP MFA setup flow for the current temp-pending session.""" + r = client.post("/api/auth/totp/setup") + if r.status_code != 200: + return None, r + secret = r.get_json()["secret"] + code = pyotp.TOTP(secret).now() + r2 = client.post("/api/auth/totp/verify", json={"code": code}) + client.post("/api/auth/mfa/complete") + return secret, r2 + + +def root_client(app, db, setup_mfa=True): + """Return a fully-authenticated Root test client (TOTP configured).""" + c = app.test_client() + pw = get_temp_password(db, 0) + r = c.post("/api/auth/login", json={"code": pw, "view": "control"}) + assert r.status_code == 200, r.get_json() + if setup_mfa: + secret, _ = setup_totp(c) + assert secret, "root TOTP setup failed" + st = c.get("/api/auth/status").get_json() + assert st["authenticated"], st + return c + + +def reauth_totp(client, secret): + code = pyotp.TOTP(secret).now() + return client.post("/api/auth/reauth", json={"code": code}) + + +def b64url(hexstr): + return base64.urlsafe_b64encode(bytes.fromhex(hexstr)).rstrip(b"=").decode() + + +print("=" * 60) +print("TEST 1: Fresh DB bootstrap → Root user + temp password + MFA setup") +print("=" * 60) +tmp = tempfile.mkdtemp() +app, db = fresh_app(tmp) +_EMITTED_PASSWORDS.clear() +init_db(db) +check("Root user exists with id 0", get_user(0, db) is not None) +root = get_user(0, db) +check("Root role is admin", root["role"] == "admin" and root["remark"] == "Root") +pw = get_temp_password(db, 0) +check("Root temp password generated", bool(pw)) +r = login_temp(app, pw) +check("Temp-password login → temp_pending", r.status_code == 200 and r.get_json().get("must_configure_mfa")) +c = app.test_client() +c.post("/api/auth/login", json={"code": pw, "view": "control"}) +st = c.get("/api/auth/status").get_json() +check("Status reports temp_pending", st["temp_pending"] is True) +secret, _ = setup_totp(c) +st = c.get("/api/auth/status").get_json() +check("Root fully authenticated after TOTP setup", st["authenticated"] is True) +check("Root is admin in status", st["user"]["is_admin"] is True) +check("Root remark visible (not id)", st["user"]["remark"] == "Root") +# TOTP login path +c2 = app.test_client() +r = c2.post("/api/auth/login", json={"code": pyotp.TOTP(secret).now(), "view": "control"}) +check("Root TOTP login works", r.status_code == 200 and not r.get_json().get("must_configure_mfa")) +# wrong code +r = c2.post("/api/auth/login", json={"code": "000000", "view": "control"}) +check("Wrong code rejected", r.status_code == 401) + +print() +print("=" * 60) +print("TEST 2: Legacy single-user DB migration → data becomes Root's") +print("=" * 60) +# Build a legacy single-user database (old schema: no users/user_id, pin-only auth) +legacy = os.path.join(tmp, "legacy.db") +import sqlite3 as _sq +_lc = _sq.connect(legacy) +_lc.executescript(""" +CREATE TABLE calendar_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#4A90D9', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now'))); +CREATE TABLE calendar_sources (id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, url TEXT, ics_data TEXT, last_sync TEXT); +CREATE TABLE calendar_events (id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER NOT NULL, uid TEXT, summary TEXT, description TEXT, location TEXT, dtstart TEXT, dtend TEXT, is_all_day INTEGER DEFAULT 0); +CREATE TABLE reminders (id INTEGER PRIMARY KEY AUTOINCREMENT, calendar_group_id INTEGER NOT NULL, minutes_before INTEGER DEFAULT 15, enabled INTEGER DEFAULT 1); +CREATE TABLE alarms (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT DEFAULT '', time TEXT NOT NULL, days TEXT DEFAULT '1,2,3,4,5,6,7', enabled INTEGER DEFAULT 1, sound TEXT DEFAULT 'default', once INTEGER DEFAULT 0); +CREATE TABLE auth_config (id INTEGER PRIMARY KEY AUTOINCREMENT, pin TEXT DEFAULT ''); +CREATE TABLE clock_config (id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT DEFAULT 'Beijing', timezone TEXT DEFAULT 'Asia/Shanghai', is_primary INTEGER DEFAULT 1); +CREATE TABLE camera_config (id INTEGER PRIMARY KEY AUTOINCREMENT, enabled INTEGER DEFAULT 0); +""") +_lc.execute("INSERT INTO calendar_groups (name) VALUES ('Legacy Group 1')") +_lc.execute("INSERT INTO calendar_groups (name) VALUES ('Legacy Group 2')") +_lc.execute("INSERT INTO calendar_groups (name) VALUES ('Legacy Group 3')") +_lc.execute("INSERT INTO alarms (label, time) VALUES ('legacy alarm', '07:30')") +_lc.execute("INSERT INTO auth_config (id, pin) VALUES (1, '0411')") +_lc.execute("INSERT INTO clock_config (city, timezone, is_primary) VALUES ('Beijing', 'Asia/Shanghai', 1)") +_lc.commit() +_lc.close() +_EMITTED_PASSWORDS.clear() +init_db(legacy) +check("Root created after legacy migration", get_user(0, legacy) is not None) +check("Legacy calendar groups → Root (user 0)", _groups_for(legacy) >= 3) +check("Legacy alarms migrated to user 0", _count(legacy, "alarms", 0) >= 1) +check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(legacy, 0))) +import sqlite3 +conn = sqlite3.connect(legacy) +tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")] +conn.close() +check("camera_config table dropped", "camera_config" not in tables) +check("users table exists", "users" in tables) + +print() +print("=" * 60) +print("TEST 3: Round-trip migration (new schema → new schema)") +print("=" * 60) +db3 = os.path.join(tmp, "roundtrip.db") +init_db(db3) +_EMITTED_PASSWORDS.clear() +uid = __import__("backend.models", fromlist=["create_user"]).create_user("Round Trip", db_path=db3) +import sqlite3 +conn = sqlite3.connect(db3) +conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,)) +conn.commit() +conn.close() +init_db(db3) +users = get_users(db3) +check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users)) +conn = sqlite3.connect(db3) +n = conn.execute("SELECT COUNT(*) FROM alarms WHERE user_id=? AND label='test'", (uid,)).fetchone()[0] +conn.close() +check("User data preserved through migration", n == 1) + +print() +print("=" * 60) +print("TEST 4: MFA-configured DB migration → no new temp password") +print("=" * 60) +db4 = os.path.join(tmp, "mfa.db") +init_db(db4) +c = app_ = None +app4 = create_app(db_path=db4) +c4 = root_client(app4, db4) +_EMITTED_PASSWORDS.clear() +init_db(db4) # migrate +check("No temp password for MFA-configured root", get_temp_password(db4, 0) == "") +cfg = get_auth_config(db4, 0) +check("must_configure_mfa cleared", cfg["must_configure_mfa"] == 0) + +print() +print("=" * 60) +print("TEST 5: Admin registration (admin-fills) + TOTP provisioning + login") +print("=" * 60) +db5 = os.path.join(tmp, "adm.db") +app5 = create_app(db_path=db5) +root = root_client(app5, db5) +root_secret = None # captured below via get_auth_config +root_secret = get_auth_config(db5, 0)["totp_secret"] +# Reauth required first +r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"}) +check("Admin op without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required")) +rr = reauth_totp(root, root_secret) +check("TOTP reauth succeeds", rr.status_code == 200 and rr.get_json()["ok"]) +r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"}) +check("Admin creates user (admin-fills)", r.status_code == 201, str(r.get_json())) +user_a_id = r.get_json()["user"]["id"] +check("New user is not admin", r.get_json()["user"]["is_admin"] is False) +# default remark = id when none given +r2 = root.post("/api/admin/users", json={"mode": "admin_fills"}) +check("Remark defaults to user id", r2.status_code == 201 and r2.get_json()["user"]["remark"] == str(r2.get_json()["user"]["id"])) +user_b_id = r2.get_json()["user"]["id"] +# Provision TOTP for user A +r = root.post(f"/api/admin/users/{user_a_id}/totp/begin") +check("Admin provisions TOTP secret", r.status_code == 200 and r.get_json().get("secret")) +secret_a = r.get_json()["secret"] +r = root.post(f"/api/admin/users/{user_a_id}/totp/verify", json={"code": pyotp.TOTP(secret_a).now()}) +check("Admin enables TOTP after verify", r.status_code == 200) +# Provision TOTP for user B too (needed for the isolation test) +r = root.post(f"/api/admin/users/{user_b_id}/totp/begin") +secret_b = r.get_json()["secret"] +root.post(f"/api/admin/users/{user_b_id}/totp/verify", json={"code": pyotp.TOTP(secret_b).now()}) +# User A logs in +ua = app5.test_client() +r = ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +check("User A TOTP login", r.status_code == 200) +st = ua.get("/api/auth/status").get_json() +check("User A authenticated, not admin", st["authenticated"] and st["user"]["is_admin"] is False) +check("User A remark correct", st["user"]["remark"] == "User A") +# User A cannot access admin APIs +r = ua.get("/api/admin/users") +check("Regular user blocked from admin list", r.status_code == 403) +r = ua.post("/api/admin/users", json={"remark": "x", "mode": "admin_fills"}) +check("Regular user blocked from user creation", r.status_code == 403) + +print() +print("=" * 60) +print("TEST 6: Temp account lifecycle (user-fills mode)") +print("=" * 60) +r = root.post("/api/admin/users", json={"remark": "Temp User", "mode": "user_fills"}) +check("Admin creates temp account", r.status_code == 201 and r.get_json()["is_temp"]) +temp_id = r.get_json()["user"]["id"] +temp_secret = get_temp_totp_secret(temp_id, db5) +check("Temp TOTP secret stored server-side", bool(temp_secret)) +r = root.get(f"/api/admin/users/{temp_id}/totp") +check("Admin sees real-time temp TOTP", r.status_code == 200 and r.get_json()["code"] == pyotp.TOTP(temp_secret).now()) +# ensure no secret leakage in admin list +users_json = root.get("/api/admin/users").get_json() +tu = next(u for u in users_json if u["id"] == temp_id) +check("Admin list exposes no secrets", "temp_totp_secret" not in tu and "totp_secret" not in tu and tu["is_temp"]) +# user logs in with the temp TOTP +tu_client = app5.test_client() +r = tu_client.post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"}) +check("Temp user login via temp TOTP", r.status_code == 200 and r.get_json().get("must_configure_mfa")) +st = tu_client.get("/api/auth/status").get_json() +check("Temp user forced to pending", st["temp_pending"] is True and st["user"]["is_temp"]) +# user configures their OWN TOTP → account finalized +r = tu_client.post("/api/auth/totp/setup") +new_secret = r.get_json()["secret"] +r = tu_client.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(new_secret).now()}) +check("Temp account finalized on TOTP verify", r.status_code == 200 and r.get_json()["finalized"]) +check("Account no longer temp", is_temp_account(temp_id, db5) is False) +check("Temp TOTP secret cleared", get_temp_totp_secret(temp_id, db5) == "") +r = root.get(f"/api/admin/users/{temp_id}/totp") +check("Admin can no longer see temp TOTP", r.status_code == 400) +# old temp TOTP no longer valid +r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"}) +check("Old temp TOTP invalidated", r.status_code == 401) +# new TOTP works +r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(new_secret).now(), "view": "control"}) +check("New TOTP login works", r.status_code == 200) + +print() +print("=" * 60) +print("TEST 7: Self-service request → admin confirm → temp account") +print("=" * 60) +req_client = app5.test_client() +r = req_client.post("/api/auth/register-request", json={"remark": "Bob"}) +check("Public register request accepted", r.status_code == 201) +users_json = root.get("/api/admin/users").get_json() +pending = [u for u in users_json if u["status"] == "pending"] +check("Request appears as pending", len(pending) == 1 and pending[0]["remark"] == "Bob") +req_id = pending[0]["id"] +r = root.post("/api/admin/users/confirm", json={"request_id": req_id}) +check("Admin confirms request → temp account", r.status_code == 200) +check("Confirmed account is temp", is_temp_account(req_id, db5)) +r = root.post("/api/admin/users/confirm", json={"request_id": req_id}) +check("Double confirm rejected", r.status_code == 400) +bob_secret = get_temp_totp_secret(req_id, db5) +r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"}) +check("Bob can log in with temp TOTP", r.status_code == 200) + +print() +print("=" * 60) +print("TEST 8: Data isolation between users") +print("=" * 60) +ua = app5.test_client() +ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +# User A creates data +r = ua.post("/api/calendar/groups", json={"name": "A's Group"}) +group_a = r.get_json()["id"] +ua.post("/api/alarms", json={"label": "A alarm", "time": "09:00"}) +from datetime import datetime, timedelta +_future = (datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%S") +r = ua.post("/api/calendar/events", json={"group_id": group_a, "summary": "A event", "dtstart": _future}) +check("User A creates data", r.status_code == 201) +# User B (the default-remark user) sees none of it +ub = app5.test_client() +ub.post("/api/auth/login", json={"code": pyotp.TOTP(secret_b).now(), "view": "control"}) +r = ub.get("/api/calendar/groups").get_json() +check("User B sees no groups", r == []) +r = ub.get("/api/alarms").get_json() +check("User B sees no alarms", r == []) +r = ub.get("/api/display/state").get_json() +check("User B state has no A events", r["events"] == [] and r["alarms"] == []) +# User B cannot tamper with A's group +r = ub.delete(f"/api/calendar/groups/{group_a}") +check("User B cannot delete A's group (404)", r.status_code == 404) +r = ua.get("/api/calendar/groups").get_json() +check("A's group untouched", any(g["id"] == group_a for g in r)) +# User A cannot fetch B's events (B has none) — and cannot touch admin stuff +r = ua.get(f"/api/calendar/groups/{group_a}/events").get_json() +check("A sees own group events", len(r) == 1) + +print() +print("=" * 60) +print("TEST 9: Edit remark + delete user + session invalidation") +print("=" * 60) +# Fresh root session → no re-auth marker → admin ops must demand reauth. +fresh_root = app5.test_client() +fresh_root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) +r = fresh_root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"}) +check("Edit remark without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required")) +rr = reauth_totp(root, root_secret) +r = root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"}) +check("Edit remark with reauth", r.status_code == 200 and r.get_json()["user"]["remark"] == "Renamed A") +# delete B (with reauth) +rr = reauth_totp(root, root_secret) +r = root.delete(f"/api/admin/users/{user_b_id}") +check("Delete user with reauth", r.status_code == 200) +check("User gone", get_user(user_b_id, db5) is None) +# B's old session invalid +r = ub.get("/api/alarms") +check("Deleted user's session invalidated", r.status_code == 401) +# cannot delete root / self +r = root.delete("/api/admin/users/0") +check("Cannot delete Root", r.status_code == 400) +r = root.delete(f"/api/admin/users/{0}") +check("Self-delete guard (root)", r.status_code == 400) + +print() +print("=" * 60) +print("TEST 10: kick_all (admin only) invalidates all sessions") +print("=" * 60) +ua2 = app5.test_client() +ua2.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +r = ua2.post("/api/display/kick_all") +check("Regular user blocked from kick_all", r.status_code == 403) +r = root.post("/api/display/kick_all") +check("Admin kick_all works", r.status_code == 200) +r = ua2.get("/api/alarms") +check("User A session invalidated by kick_all", r.status_code == 401) +r = root.get("/api/alarms") +check("Root session also invalidated by kick_all", r.status_code == 401) +# re-login works after kick_all +r = root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) +check("Re-login after kick_all", r.status_code == 200) + +print() +print("=" * 60) +print("TEST 11: SSE / EventBus user scoping") +print("=" * 60) +bus = EventBus() +q1 = bus.subscribe(user_id=1) +q2 = bus.subscribe(user_id=2) +qall = bus.subscribe(None) +bus.publish("alarm_triggered", {"alarms": []}, user_id=1) +def q_get(q, timeout=0.1): + try: + return q.get(timeout=timeout) + except Exception: + return None +check("User 1 receives own event", q_get(q1) is not None) +check("User 2 does NOT receive user 1 event", q_get(q2, 0.05) is None) +check("Global subscriber receives all events", q_get(qall) is not None) +bus.publish("global_event", {}, user_id=None) +check("Global event reaches all subscribers", q_get(q2) is not None) + +print() +print("=" * 60) +print("TEST 12: Rate limit on register-request") +print("=" * 60) +for i in range(5): + app5.test_client().post("/api/auth/register-request", json={"remark": f"spam{i}"}) +r = app5.test_client().post("/api/auth/register-request", json={"remark": "spam5"}) +check("6th register request rate-limited", r.status_code == 429) + +print() +print("=" * 60) +print("TEST 13: CSRF origin check on admin operations") +print("=" * 60) +r = root.post("/api/admin/users", json={"remark": "CSRF", "mode": "admin_fills"}, + headers={"Origin": "http://evil.example"}) +check("Cross-origin admin POST blocked", r.status_code == 403) +r = root.get("/api/admin/users", headers={"Origin": "http://evil.example"}) +check("Cross-origin GET allowed (safe)", r.status_code == 200) + +print() +print("=" * 60) +print("TEST 14: Passkey flows (mocked ceremonies)") +print("=" * 60) +from backend import webauthn_mgr +import backend.routes as routes_mod + +CRED_HEX = "aabbccddeeff00112233445566778899" +CRED_B64 = b64url(CRED_HEX) + +orig_begin_reg = routes_mod.begin_registration +orig_complete_reg = routes_mod.complete_registration +orig_begin_auth = routes_mod.begin_authentication +orig_complete_auth = routes_mod.complete_authentication + +routes_mod.begin_registration = lambda request, user_id, display_name="", existing_credential_id=None: { + "challenge": "AA", "user": {"id": b64url(f"user_{user_id}".encode().hex()), "name": f"user_{user_id}", "displayName": display_name}, +} +routes_mod.complete_registration = lambda request, credential: { + "ok": True, "user_id": int(credential.get("user_name", "user_0").split("_")[-1]), + "credential_id": CRED_HEX, "public_key": "abcdef", +} +routes_mod.begin_authentication = lambda request, allow_credential_ids=None: {"challenge": "BB"} +def _cred_hex(credential): + import base64 + cid = credential.get("id", "") + try: + padded = cid + "=" * ((4 - len(cid) % 4) % 4) + return base64.urlsafe_b64decode(padded.encode("ascii")).hex() + except Exception: + return "unknown" +routes_mod.complete_authentication = lambda request, credential, stored_credential_id=None, stored_public_key="": { + "ok": True, "credential_id": _cred_hex(credential), +} + +db14 = os.path.join(tmp, "passkey.db") +app14 = create_app(db_path=db14) +root14 = root_client(app14, db14) +root14_secret = get_auth_config(db14, 0)["totp_secret"] +rr = reauth_totp(root14, root14_secret) +r = root14.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"}) +pk_uid = r.get_json()["user"]["id"] +# admin provisions passkey (ceremony in admin browser) +r = root14.post(f"/api/admin/users/{pk_uid}/passkey/begin") +check("Admin passkey begin", r.status_code == 200 and "challenge" in r.get_json()) +r = root14.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}}) +check("Admin passkey complete stored", r.status_code == 200) +# passkey login for that user +c = app14.test_client() +r = c.post("/api/auth/webauthn/login/begin") +check("Passkey login begin", r.status_code == 200) +r = c.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}}) +check("Passkey login identifies user", r.status_code == 200) +st = c.get("/api/auth/status").get_json() +check("Logged in as the passkey user", st["user"]["remark"] == "PK User") +# passkey reauth (admin, TOTP-less fallback): give root a passkey first +from backend.models import update_auth_config as uac +uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db14) +root14_c2 = app14.test_client() +root14_c2.post("/api/auth/login", json={"code": pyotp.TOTP(root14_secret).now(), "view": "control"}) +r = root14_c2.post("/api/auth/reauth/webauthn/begin") +check("Passkey reauth begin", r.status_code == 200) +# complete with an unknown credential should fail (root's stored differs) +r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}}) +check("Passkey reauth rejects wrong credential", r.status_code == 401) +r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}}) +check("Passkey reauth succeeds with matching credential", r.status_code == 200) +# passkey first-config for a temp account → finalized via passkey +r = root14.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"}) +tuid = r.get_json()["user"]["id"] +tsec = get_temp_totp_secret(tuid, db14) +tc = app14.test_client() +tc.post("/api/auth/login", json={"code": pyotp.TOTP(tsec).now(), "view": "control"}) +r = tc.post("/api/auth/webauthn/register/begin") +check("Temp user passkey register begin", r.status_code == 200) +r = tc.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}}) +check("Temp user passkey register → finalized", r.status_code == 200 and r.get_json()["finalized"]) +check("Temp account finalized via passkey", is_temp_account(tuid, db14) is False) +r = root14.get(f"/api/admin/users/{tuid}/totp") +check("Temp TOTP gone after passkey finalize", r.status_code == 400) + +# restore +routes_mod.begin_registration = orig_begin_reg +routes_mod.complete_registration = orig_complete_reg +routes_mod.begin_authentication = orig_begin_auth +routes_mod.complete_authentication = orig_complete_auth + +print() +print("=" * 60) +print("TEST 15: Scheduler thread context (per-user timezone, no request context)") +print("=" * 60) +from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id +from backend.models import create_user as cu +import sqlite3 + +db15 = os.path.join(tmp, "sched.db") +app15 = create_app(db_path=db15) +xid = cu("Sched User", db_path=db15) +conn = sqlite3.connect(db15) +conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,)) +conn.commit() +conn.close() +set_thread_user(xid) +with app15.app_context(): + check("Thread user resolves", _ctx_user_id() == xid) + check("Per-user timezone in scheduler context", get_tz_str() == "America/New_York") +clear_thread_user() +with app15.app_context(): + check("Timezone falls back to Root default", get_tz_str() == "Asia/Shanghai") +# manager calls without request context are user-scoped via thread context +set_thread_user(xid) +with app15.app_context(): + from backend import alarm_mgr, countdown_mgr + r = alarm_mgr.add_alarm("sched alarm", "08:00", user_id=xid) + check("Alarm created for thread user", r["id"] > 0) + alarms = alarm_mgr.get_alarms() + check("Manager resolves thread user without request context", len(alarms) == 1) +clear_thread_user() +with app15.app_context(): + alarms0 = alarm_mgr.get_alarms(user_id=0) + check("Root sees no other user's alarms", alarms0 == []) +# scheduler boots and runs per-user jobs without crashing +from backend.scheduler import start_scheduler, stop_scheduler +start_scheduler(app15) +import time +time.sleep(1.5) +stop_scheduler() +check("Scheduler starts and stops cleanly", True) + +print() +print("=" * 60) +print("TEST 16: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)") +print("=" * 60) +from backend.url_util import is_safe_public_url, is_safe_https_host +check("SSRF: loopback URL rejected", is_safe_public_url("http://127.0.0.1/x.ics") is False) +check("SSRF: cloud metadata rejected", is_safe_public_url("http://169.254.169.254/latest/meta-data") is False) +check("SSRF: private IP rejected", is_safe_public_url("http://10.0.0.5/cal.ics") is False) +check("SSRF: public URL accepted", is_safe_public_url("https://example.com/cal.ics") is True) +check("Host: private host rejected", is_safe_https_host("127.0.0.1") is False) +check("Host: public host accepted", is_safe_https_host("example.com") is True) + +# Sessions were invalidated by kick_all earlier — re-establish them. +ua = app5.test_client() +ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +# User B was deleted in TEST 9 — create a fresh user C for cross-user checks. +rr = reauth_totp(root, root_secret) +r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"}) +uc_id = r.get_json()["user"]["id"] +r = root.post(f"/api/admin/users/{uc_id}/totp/begin") +secret_c = r.get_json()["secret"] +root.post(f"/api/admin/users/{uc_id}/totp/verify", json={"code": pyotp.TOTP(secret_c).now()}) +uc = app5.test_client() +uc.post("/api/auth/login", json={"code": pyotp.TOTP(secret_c).now(), "view": "control"}) + +# reauth requires an authenticated session (no public TOTP oracle) +anon = app5.test_client() +r = anon.post("/api/auth/reauth", json={"code": pyotp.TOTP(secret_a).now()}) +check("Reauth blocked for unauthenticated caller", r.status_code == 401) + +# subscription SSRF rejection + cross-user calendar write rejection +rr = reauth_totp(root, root_secret) +r = ua.post("/api/calendar/sources/subscribe", json={"group_id": group_a, "name": "evil", "url": "http://127.0.0.1/x.ics"}) +check("Subscription with unsafe URL rejected", r.status_code == 400) +r = ua.post("/api/calendar/sources/subscribe", json={"group_id": 999999, "name": "x", "url": "https://example.com/x.ics"}) +check("Subscription to foreign group rejected", r.status_code == 400) +# user B cannot write into A's group +r = uc.post("/api/calendar/events", json={"group_id": group_a, "summary": "B intrusion", "dtstart": "20300101T090000"}) +check("Cross-user event write rejected", r.status_code == 400) +r = uc.post("/api/calendar/sources/upload", data={"group_id": group_a, "name": "x", "file": (io.BytesIO(b"BEGIN:VCALENDAR"), "x.ics")}, content_type="multipart/form-data") +check("Cross-user ICS upload rejected", r.status_code == 400) +# qweather host SSRF +r = ua.put("/api/weather/config", json={"qweather_host": "127.0.0.1"}) +check("Unsafe qweather host rejected", r.status_code == 400) + +# cascade delete with full data (FK-safe order) +r = root.post("/api/admin/users", json={"remark": "Full Data", "mode": "admin_fills"}) +fd_id = r.get_json()["user"]["id"] +r = root.post(f"/api/admin/users/{fd_id}/totp/begin") +fd_secret = r.get_json()["secret"] +root.post(f"/api/admin/users/{fd_id}/totp/verify", json={"code": pyotp.TOTP(fd_secret).now()}) +fc = app5.test_client() +fc.post("/api/auth/login", json={"code": pyotp.TOTP(fd_secret).now(), "view": "control"}) +g = fc.post("/api/calendar/groups", json={"name": "g"}).get_json() +fc.post("/api/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"}) +fc.post("/api/alarms", json={"label": "a", "time": "10:00"}) +fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999}) +import sqlite3 as _sqlite3 +conn = _sqlite3.connect(db5) +before = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] + for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state"]} +conn.close() +check("Cascade test user has data", sum(before.values()) >= 5) +rr = reauth_totp(root, root_secret) +r = root.delete(f"/api/admin/users/{fd_id}") +check("Delete full-data user succeeds", r.status_code == 200) +conn = _sqlite3.connect(db5) +after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before} +conn.close() +check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after)) +check("Deleted user's live session invalidated", fc.get("/api/alarms").status_code == 401) + +print() +print("=" * 60) +print("TEST 18: Passkey stash 一次性参数暂存(小窗传参协商)") +print("=" * 60) +db18 = os.path.join(tmp, "stash.db") +app18 = create_app(db_path=db18) +c18 = app18.test_client() +opts = {"challenge": "ch123", "rp": {"id": "d.example.com"}, "user": {"id": "u1", "name": "user_1"}} +r = c18.post("/api/passkey/stash", json={"op": "creation", "options": opts}) +check("stash 创建返回 sid", r.status_code == 201 and r.get_json().get("sid")) +sid = r.get_json()["sid"] +r = c18.get(f"/api/passkey/stash/{sid}") +check("stash 一次性消费返回参数", r.status_code == 200 and r.get_json()["op"] == "creation" and r.get_json()["options"]["rp"]["id"] == "d.example.com") +r = c18.get(f"/api/passkey/stash/{sid}") +check("stash 参数可重复读取(entry 保留至结果取走)", r.status_code == 200) +r = c18.get("/api/passkey/stash/notexist") +check("stash 未知 sid 返回 410", r.status_code == 410) +r = c18.post("/api/passkey/stash", json={"op": "bad", "options": {}}) +check("stash 非法 op 拒绝", r.status_code == 400) +# 与中间件协议一致性:登录/注册/二次验证用的 options 均含 challenge + rp.id +r = c18.post("/api/passkey/stash", json={"op": "assertion", "options": {"challenge": "a", "rp": {"id": "d.example.com"}}}) +sid2 = r.get_json()["sid"] +r = c18.get(f"/api/passkey/stash/{sid2}") +check("assertion 参数一致性", r.get_json()["options"]["challenge"] == "a") + +# result 接口:小窗完成 → POST 结果 → App GET 取回(一次性) +r = c18.post("/api/passkey/stash", json={"op": "creation", "options": {"challenge": "b", "rp": {"id": "d.example.com"}}}) +sid3 = r.get_json()["sid"] +cred = {"id": "cred-abc", "type": "public-key", "response": {"clientDataJSON": "x", "attestationObject": "y" * 2000, "transports": []}} +r = c18.post(f"/api/passkey/stash/{sid3}/result", json={"credential": cred, "user_name": "user_1"}) +check("result 保存成功", r.status_code == 200 and r.get_json()["ok"]) +r = c18.get(f"/api/passkey/stash/{sid3}/result") +check("App 取回完整 credential(含大数据)", r.status_code == 200 and r.get_json()["credential"]["id"] == "cred-abc" and r.get_json()["user_name"] == "user_1") +r = c18.get(f"/api/passkey/stash/{sid3}/result") +check("result 二次取回返回 410", r.status_code == 410) +# 未 POST 结果前 GET → 410 +r = c18.post("/api/passkey/stash", json={"op": "assertion", "options": {"challenge": "c", "rp": {"id": "d.example.com"}}}) +sid4 = r.get_json()["sid"] +r = c18.get(f"/api/passkey/stash/{sid4}/result") +check("未保存结果时 GET 返回 410", r.status_code == 410) +# 真实时序回归:小窗先 GET 参数(只读,不消费)→ 完成认证 → POST result 必须成功 +r = c18.post("/api/passkey/stash", json={"op": "creation", "options": {"challenge": "d", "rp": {"id": "d.example.com"}}}) +sid5 = r.get_json()["sid"] +r = c18.get(f"/api/passkey/stash/{sid5}") +check("小窗读取参数成功", r.status_code == 200) +r = c18.post(f"/api/passkey/stash/{sid5}/result", json={"credential": {"id": "reg-final", "type": "public-key", "response": {}}}) +check("读取参数后仍可 POST result(修复 410 回归)", r.status_code == 200) + +print() +print("=" * 60) +print("TEST 19: 账户独立主题(dark/light/auto)") +print("=" * 60) +db19 = os.path.join(tmp, "theme.db") +app19 = create_app(db_path=db19) +c19 = app19.test_client() +pw19 = get_temp_password(db19, 0) +c19.post("/api/auth/login", json={"code": pw19, "view": "control"}) +c19.post("/api/auth/totp/setup") +sec19 = get_auth_config(db19, 0)["totp_secret"] +c19.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(sec19).now()}) +c19.post("/api/auth/mfa/complete") + +r = c19.get("/api/theme") +check("默认主题为 dark", r.status_code == 200 and r.get_json()["mode"] == "dark" and r.get_json()["effective"] == "dark") +r = c19.put("/api/theme", json={"mode": "light"}) +check("保存 light → effective light", r.status_code == 200 and r.get_json()["effective"] == "light") +r = c19.put("/api/theme", json={"mode": "auto"}) +check("auto 解析为 dark/light 之一", r.status_code == 200 and r.get_json()["effective"] in ("dark", "light")) +r = c19.put("/api/theme", json={"mode": "bad"}) +check("非法模式拒绝", r.status_code == 400) + +# 账户独立:创建用户 A/B,各自设置互不影响 +from backend.models import create_user as cu19, update_user as uu19 +ua19 = cu19("Theme A", db_path=db19) +ub19 = cu19("Theme B", db_path=db19) +uu19(ua19, theme="light", db_path=db19) +uu19(ub19, theme="dark", db_path=db19) +check("账户 A 主题=light", get_user(ua19, db19)["theme"] == "light") +check("账户 B 主题=dark", get_user(ub19, db19)["theme"] == "dark") + +# 未登录访问 /api/theme → 401 +anon19 = app19.test_client() +check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code == 401) + +print() +print("=" * 60) +print(f"RESULT: {PASS} passed, {FAIL} failed") +print("=" * 60) +sys.exit(1 if FAIL else 0)