From 908b4b19ea1df7aa065772e98213ddcb9d84ce53 Mon Sep 17 00:00:00 2001 From: Qi HU Date: Mon, 13 Oct 2025 15:57:41 +0800 Subject: [PATCH 1/2] feat(attendance): enforce captcha validation before check-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - require a successful captcha challenge before recording the day’s attendance - preserve the original attendance summary layout after a successful check-in Signed-off-by: Qi HU --- lang/chs/lang_attendance.php | 1 + lang/cht/lang_attendance.php | 1 + lang/en/lang_attendance.php | 1 + public/attendance.php | 177 ++++++++++++++++++++--------------- 4 files changed, 106 insertions(+), 74 deletions(-) diff --git a/lang/chs/lang_attendance.php b/lang/chs/lang_attendance.php index dd9d617b..46e9a9bc 100644 --- a/lang/chs/lang_attendance.php +++ b/lang/chs/lang_attendance.php @@ -13,4 +13,5 @@ $lang_attendance = array 'retroactive_event_text' => '补', 'retroactive_confirm_tip' => '确定要补签: ', 'retroactive_description' => '点击白色背景的圆点进行补签。你目前拥有补签卡 %d 张。', + 'attend_button' => '立即签到', ); diff --git a/lang/cht/lang_attendance.php b/lang/cht/lang_attendance.php index 1b6db754..41eaa9c3 100644 --- a/lang/cht/lang_attendance.php +++ b/lang/cht/lang_attendance.php @@ -13,4 +13,5 @@ $lang_attendance = array 'retroactive_event_text' => '補', 'retroactive_confirm_tip' => '確定要補簽: ', 'retroactive_description' => '點擊白色背景的圓點進行補簽。你目前擁有補簽卡 %d 張。', + 'attend_button' => '立即簽到', ); diff --git a/lang/en/lang_attendance.php b/lang/en/lang_attendance.php index c5faadde..3899c91d 100644 --- a/lang/en/lang_attendance.php +++ b/lang/en/lang_attendance.php @@ -13,4 +13,5 @@ $lang_attendance = array 'retroactive_event_text' => 'Re', 'retroactive_confirm_tip' => 'Confirm to attend: ', 'retroactive_description' => 'Click on the dot on the white background to do attend. You currently have a attendance card %d.', + 'attend_button' => 'Check in now', ); diff --git a/public/attendance.php b/public/attendance.php index efb5551b..dbb26f48 100644 --- a/public/attendance.php +++ b/public/attendance.php @@ -4,33 +4,6 @@ dbconn(); require get_langfile_path(); loggedinorreturn(); parked(); -//$desk = new Attendance($CURUSER['id']); -// -//if($result = $desk->attend($attendance_initial_bonus, $attendance_step_bonus, $attendance_max_bonus, $attendance_continuous_bonus)){ -// list($count, $cdays, $points) = $result; -// stdhead($lang_attendance['title']); -// begin_main_frame(); -// begin_frame($lang_attendance['success']); -// printf('

'.$lang_attendance['attend_info'].'

', $count, $cdays, $points); -// end_frame(); -// echo ''; -// end_main_frame(); -// stdfoot(); -//}else{ -// stderr($lang_attendance['sorry'], $lang_attendance['already_attended']); -//} - -\Nexus\Nexus::css('vendor/fullcalendar-5.10.2/main.min.css', 'header', true); -\Nexus\Nexus::js('vendor/fullcalendar-5.10.2/main.min.js', 'footer', true); - $lang = get_langfolder_cookie(); $localesMap = [ 'en' => 'en-us', @@ -38,44 +11,99 @@ $localesMap = [ 'cht' => 'zh-tw', ]; $localeJs = $localesMap[$lang] ?? 'en-us'; + +\Nexus\Nexus::css('vendor/fullcalendar-5.10.2/main.min.css', 'header', true); +\Nexus\Nexus::js('vendor/fullcalendar-5.10.2/main.min.js', 'footer', true); \Nexus\Nexus::js("vendor/fullcalendar-5.10.2/locales/{$localeJs}.js", 'footer', true); +$rep = new \App\Repositories\AttendanceRepository(); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($iv == "yes") { + check_code($_POST['imagehash'] ?? null, $_POST['imagestring'] ?? null, 'attendance.php'); + } + $attendance = $rep->attend($CURUSER['id']); + if (!$attendance->is_updated) { + stderr($lang_attendance['sorry'], $lang_attendance['already_attended']); + } +} else { + $attendance = $rep->getAttendance($CURUSER['id']); + if (!$attendance) { + $attendance = new \App\Models\Attendance([ + 'uid' => $CURUSER['id'], + 'points' => 0, + 'days' => 0, + 'total_days' => 0, + ]); + $attendance->added = null; + } +} + $today = \Carbon\Carbon::today(); $tomorrow = \Carbon\Carbon::tomorrow(); $end = $today->clone()->endOfMonth(); $start = $today->clone()->subMonth(2); -$rep = new \App\Repositories\AttendanceRepository(); -$attendance = $rep->attend($CURUSER['id']); -$logs = $attendance->logs()->where('date', '>=', $start->format('Y-m-d'))->get()->keyBy('date'); -$interval = new \DateInterval('P1D'); -$period = new \DatePeriod($start, $interval, $end); -$interval = \Carbon\CarbonInterval::make($interval); -$period = \Carbon\CarbonPeriod::make($period); -$events = []; -foreach ($period as $value) { - if ($value->gte($tomorrow)) { - continue; - } - $checkDate = $value->format('Y-m-d'); - $eventBase = ['start' => $checkDate, 'end' => $checkDate]; - if ($logs->has($checkDate)) { - $logValue = $logs->get($checkDate); - $events[] = array_merge($eventBase, ['display' => 'background']); - if ($logValue->points > 0) { - $events[] = array_merge($eventBase, ['title' => $logValue->points]); - } - if ($logValue->is_retroactive) { - $events[] = array_merge($eventBase, ['title' => $lang_attendance['retroactive_event_text'], 'display' => 'list-item']); - } - } elseif ($value->lte($today) && $value->diffInDays($today, true) <= \App\Models\Attendance::MAX_RETROACTIVE_DAYS) { - $events[] = array_merge($eventBase, ['groupId' => 'to_do', 'display' => 'list-item']); - } -} -$eventStr = json_encode($events); -$validRangeStr = json_encode(['start' => $start->format('Y-m-d'), 'end' => $end->clone()->addDays(1)->format('Y-m-d')]); +$hasAttendedToday = $attendance->added && $attendance->added->isSameDay($today); +stdhead($lang_attendance['title']); +begin_main_frame(); -$js = <<format('Y-m-d'); + $baseQuery = \App\Models\AttendanceLog::query()->where('date', $todayDate); + $todayCounts = $baseQuery->count(); + $myLog = (clone $baseQuery)->where('uid', $CURUSER['id'])->first(['id']); + $myRanking = 0; + if ($myLog) { + $myRanking = (clone $baseQuery)->where('id', '<=', $myLog->id)->count(); + } + + $count = $attendance->total_days; + $cdays = $attendance->days; + $points = $attendance->points; + + $headerLeft = sprintf($lang_attendance['attend_info'] . $lang_attendance['retroactive_description'], $count, $cdays, $points, $CURUSER['attendance_card']); + $headerRight = nexus_trans('attendance.ranking', ['ranking' => $myRanking, 'counts' => $todayCounts]); + + begin_frame($lang_attendance['success']); + printf('

%s%s

', $headerLeft, $headerRight); + end_frame(); + + $logs = \App\Models\AttendanceLog::query() + ->where('uid', $CURUSER['id']) + ->where('date', '>=', $start->format('Y-m-d')) + ->get() + ->keyBy('date'); + $interval = new \DateInterval('P1D'); + $period = new \DatePeriod($start, $interval, $end); + + $interval = \Carbon\CarbonInterval::make($interval); + $period = \Carbon\CarbonPeriod::make($period); + $events = []; + foreach ($period as $value) { + if ($value->gte($tomorrow)) { + continue; + } + $checkDate = $value->format('Y-m-d'); + $eventBase = ['start' => $checkDate, 'end' => $checkDate]; + if ($logs->has($checkDate)) { + $logValue = $logs->get($checkDate); + $events[] = array_merge($eventBase, ['display' => 'background']); + if ($logValue->points > 0) { + $events[] = array_merge($eventBase, ['title' => $logValue->points]); + } + if ($logValue->is_retroactive) { + $events[] = array_merge($eventBase, ['title' => $lang_attendance['retroactive_event_text'], 'display' => 'list-item']); + } + } elseif ($value->lte($today) && $value->diffInDays($today, true) <= \App\Models\Attendance::MAX_RETROACTIVE_DAYS) { + $events[] = array_merge($eventBase, ['groupId' => 'to_do', 'display' => 'list-item']); + } + } + + $eventStr = json_encode($events); + $validRangeStr = json_encode(['start' => $start->format('Y-m-d'), 'end' => $end->clone()->addDays(1)->format('Y-m-d')]); + + $js = <<total_days; - $cdays = $attendance->days; - $points = $attendance->points; - - stdhead($lang_attendance['title']); - begin_main_frame(); - begin_frame($lang_attendance['success']); - $headerLeft = sprintf($lang_attendance['attend_info'].$lang_attendance['retroactive_description'], $count, $cdays, $points, $CURUSER['attendance_card']); - $headerRight = nexus_trans('attendance.ranking', ['ranking' => $attendance->my_ranking, 'counts' => $attendance->today_counts]); - printf('

%s%s

', $headerLeft, $headerRight); - end_frame(); echo '
'; echo '
    '; printf('
  • '.$lang_attendance['initial'].'
  • ', $attendance_initial_bonus); @@ -136,9 +152,22 @@ if (1) { } echo ''; echo '
'; - end_main_frame(); - stdfoot(); - } else { - stderr($lang_attendance['sorry'], $lang_attendance['already_attended']); + $buttonLabel = $lang_attendance['attend_button'] ?? 'Check in'; + begin_frame($lang_attendance['title']); + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo ''; + show_image_code(); + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + end_frame(); } +end_main_frame(); +stdfoot(); From 479ace4df7a66fa63de0f299c158e861c6310155 Mon Sep 17 00:00:00 2001 From: Qi HU Date: Tue, 14 Oct 2025 10:26:18 +0800 Subject: [PATCH 2/2] feat(attendance): make captcha requirement configurable - add Filament toggle to control whether attendance check-in requires captcha - persist the toggle under captcha.attendance.enabled with sensible defaults Signed-off-by: Qi HU --- .env.example | 1 + .../SettingResource/Pages/EditSetting.php | 25 ++++++ config/captcha.php | 4 + public/attendance.php | 86 +++++++++++-------- resources/lang/en/label.php | 4 + resources/lang/zh_CN/label.php | 4 + resources/lang/zh_TW/label.php | 4 + 7 files changed, 94 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index cbd1aa08..c3588b1b 100644 --- a/.env.example +++ b/.env.example @@ -99,6 +99,7 @@ FORCE_SCHEME= # Captcha settings # Available drivers: image, cloudflare_turnstile, google_recaptcha_v2 CAPTCHA_DRIVER=image +CAPTCHA_ATTENDANCE_ENABLED=true # Cloudflare Turnstile keys (used when CAPTCHA_DRIVER=cloudflare_turnstile) TURNSTILE_SITE_KEY= diff --git a/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php b/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php index 97ca00b6..9d3e3a31 100644 --- a/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php +++ b/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php @@ -13,6 +13,7 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\CheckboxList; use Filament\Schemas\Components\Fieldset; use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Section; use App\Auth\Permission; @@ -30,6 +31,7 @@ use Filament\Facades\Filament; use Filament\Resources\Pages\Page; use Filament\Forms; use Illuminate\Support\HtmlString; +use Illuminate\Support\Arr; use Meilisearch\Contracts\Index\Settings; use Nexus\Database\NexusDB; @@ -64,6 +66,16 @@ class EditSetting extends Page implements HasForms private function fillForm() { $settings = Setting::getFromDb(); + + $fallbackEnabled = (bool) config('captcha.attendance.enabled', true); + $rawSetting = Arr::get($settings, 'captcha.attendance.enabled', $fallbackEnabled); + if (is_string($rawSetting)) { + $normalized = in_array(strtolower($rawSetting), ['1', 'true', 'yes'], true); + } else { + $normalized = (bool) $rawSetting; + } + Arr::set($settings, 'captcha.attendance.enabled', $normalized); + $this->form->fill($settings); } @@ -298,7 +310,20 @@ class EditSetting extends Page implements HasForms Setting::get('captcha.recaptcha.size', nexus_env('RECAPTCHA_SIZE', 'normal')) ); + $attendanceCaptchaSetting = Setting::get('captcha.attendance.enabled', true); + if (is_string($attendanceCaptchaSetting)) { + $attendanceCaptchaEnabled = in_array(strtolower($attendanceCaptchaSetting), ['1', 'true', 'yes'], true); + } else { + $attendanceCaptchaEnabled = (bool) $attendanceCaptchaSetting; + } + $schema = [ + Toggle::make("$captchaPrefix.attendance.enabled") + ->label(__('label.setting.captcha.attendance.enabled')) + ->helperText(__('label.setting.captcha.attendance.enabled_help')) + ->default($attendanceCaptchaEnabled) + ->columnSpanFull() + , Select::make("$captchaPrefix.default") ->options($driverOptions) ->label(__('label.setting.captcha.driver')) diff --git a/config/captcha.php b/config/captcha.php index 021a45ad..7cd90cc9 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -24,4 +24,8 @@ return [ 'size' => nexus_env('RECAPTCHA_SIZE', 'normal'), ], ], + + 'attendance' => [ + 'enabled' => nexus_env('CAPTCHA_ATTENDANCE_ENABLED', true), + ], ]; diff --git a/public/attendance.php b/public/attendance.php index dbb26f48..67b4325e 100644 --- a/public/attendance.php +++ b/public/attendance.php @@ -1,9 +1,14 @@ 'en-us', @@ -11,40 +16,52 @@ $localesMap = [ 'cht' => 'zh-tw', ]; $localeJs = $localesMap[$lang] ?? 'en-us'; - -\Nexus\Nexus::css('vendor/fullcalendar-5.10.2/main.min.css', 'header', true); -\Nexus\Nexus::js('vendor/fullcalendar-5.10.2/main.min.js', 'footer', true); \Nexus\Nexus::js("vendor/fullcalendar-5.10.2/locales/{$localeJs}.js", 'footer', true); -$rep = new \App\Repositories\AttendanceRepository(); - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - if ($iv == "yes") { - check_code($_POST['imagehash'] ?? null, $_POST['imagestring'] ?? null, 'attendance.php'); - } - $attendance = $rep->attend($CURUSER['id']); - if (!$attendance->is_updated) { - stderr($lang_attendance['sorry'], $lang_attendance['already_attended']); - } -} else { - $attendance = $rep->getAttendance($CURUSER['id']); - if (!$attendance) { - $attendance = new \App\Models\Attendance([ - 'uid' => $CURUSER['id'], - 'points' => 0, - 'days' => 0, - 'total_days' => 0, - ]); - $attendance->added = null; - } -} - $today = \Carbon\Carbon::today(); $tomorrow = \Carbon\Carbon::tomorrow(); $end = $today->clone()->endOfMonth(); $start = $today->clone()->subMonth(2); -$hasAttendedToday = $attendance->added && $attendance->added->isSameDay($today); +$attendanceRepository = new \App\Repositories\AttendanceRepository(); + +$attendanceCaptchaSetting = \App\Models\Setting::get('captcha.attendance.enabled', config('captcha.attendance.enabled', true)); +if (is_string($attendanceCaptchaSetting)) { + $attendanceCaptchaEnabled = in_array(strtolower($attendanceCaptchaSetting), ['1', 'true', 'yes'], true); +} else { + $attendanceCaptchaEnabled = (bool) $attendanceCaptchaSetting; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($attendanceCaptchaEnabled && $iv == 'yes') { + check_code($_POST['imagehash'] ?? null, $_POST['imagestring'] ?? null, 'attendance.php'); + } + $attendance = $attendanceRepository->attend($CURUSER['id']); + if (!$attendance->is_updated) { + stderr($lang_attendance['sorry'], $lang_attendance['already_attended']); + } +} else { + $attendance = $attendanceRepository->getAttendance($CURUSER['id']); +} + +$hasAttendedToday = $attendance && $attendance->added && $attendance->added->isSameDay($today); + +if (!$attendanceCaptchaEnabled && !$hasAttendedToday) { + $attendance = $attendanceRepository->attend($CURUSER['id']); + $hasAttendedToday = $attendance && $attendance->added && $attendance->added->isSameDay($today); +} + +if (!$attendance) { + $attendance = new \App\Models\Attendance([ + 'uid' => $CURUSER['id'], + 'points' => 0, + 'days' => 0, + 'total_days' => 0, + ]); + $attendance->added = null; + $hasAttendedToday = false; +} + stdhead($lang_attendance['title']); begin_main_frame(); @@ -74,6 +91,7 @@ if ($hasAttendedToday) { ->where('date', '>=', $start->format('Y-m-d')) ->get() ->keyBy('date'); + $interval = new \DateInterval('P1D'); $period = new \DatePeriod($start, $interval, $end); @@ -103,7 +121,7 @@ if ($hasAttendedToday) { $eventStr = json_encode($events); $validRangeStr = json_encode(['start' => $start->format('Y-m-d'), 'end' => $end->clone()->addDays(1)->format('Y-m-d')]); - $js = <<
'; echo '
    '; printf('
  • '.$lang_attendance['initial'].'
  • ', $attendance_initial_bonus); printf('
  • '.$lang_attendance['steps'].'
  • ', $attendance_step_bonus, $attendance_max_bonus); echo '
    1. '; - foreach($attendance_continuous_bonus as $day => $value){ + foreach ($attendance_continuous_bonus as $day => $value) { printf('
    2. '.$lang_attendance['continuous'].'
    3. ', $day, $value); } echo '
  • '; @@ -160,7 +175,9 @@ EOP; echo '
    '; echo '
    '; echo ''; - show_image_code(); + if ($attendanceCaptchaEnabled && $iv == 'yes') { + show_image_code(); + } echo ''; echo '
    '; echo '
    '; @@ -169,5 +186,6 @@ EOP; echo ''; end_frame(); } + end_main_frame(); stdfoot(); diff --git a/resources/lang/en/label.php b/resources/lang/en/label.php index 7d5f42e7..376180bc 100644 --- a/resources/lang/en/label.php +++ b/resources/lang/en/label.php @@ -144,6 +144,10 @@ return [ 'size_normal' => 'Normal', 'size_compact' => 'Compact', ], + 'attendance' => [ + 'enabled' => 'Require captcha for attendance check-in', + 'enabled_help' => 'When enabled, members must solve the captcha before signing in.', + ], ], 'meilisearch' => [ 'tab_header' => 'Meilisearch', diff --git a/resources/lang/zh_CN/label.php b/resources/lang/zh_CN/label.php index c2843fba..c3632bc7 100644 --- a/resources/lang/zh_CN/label.php +++ b/resources/lang/zh_CN/label.php @@ -185,6 +185,10 @@ return [ 'size_normal' => '普通', 'size_compact' => '紧凑', ], + 'attendance' => [ + 'enabled' => '启用签到验证码', + 'enabled_help' => '开启后,用户每天签到前必须完成验证码验证。', + ], ], 'meilisearch' => [ 'tab_header' => 'Meilisearch', diff --git a/resources/lang/zh_TW/label.php b/resources/lang/zh_TW/label.php index 210e23dc..77521adc 100644 --- a/resources/lang/zh_TW/label.php +++ b/resources/lang/zh_TW/label.php @@ -144,6 +144,10 @@ return [ 'size_normal' => '一般', 'size_compact' => '緊湊', ], + 'attendance' => [ + 'enabled' => '啟用簽到驗證碼', + 'enabled_help' => '啟用後,使用者簽到前必須完成驗證碼驗證。', + ], ], 'meilisearch' => [ 'tab_header' => 'Meilisearch',