8 Commits

31 changed files with 1480 additions and 397 deletions

View File

@@ -1,16 +1,10 @@
# Caddyfile - Reverse Proxy Configuration
#
# 使用方法:
# 1. example.com を実際のドメインに置き換えてください
# 2. DNS の A レコードをこのサーバーの IP アドレスに向けてください
# 3. docker compose up -d で起動すると、自動的に HTTPS 証明書が取得されます
#
# ローカル開発用の場合は、以下のように変更してください
# :80 {
# reverse_proxy app:8080
# }
# 本番環境で公開する場合は、以下の `:80` を実際のドメイン(例: example.comに変更してください
# 変更して docker compose up -d を再実行すると、自動的にHTTPS証明書が取得されます。
example.com {
:80 {
reverse_proxy app:8080
# ログ設定

158
README.md
View File

@@ -1,102 +1,112 @@
# Homework Manager
<div align="center">
シンプルな課題管理アプリケーションです。学生の課題管理を効率化するために設計されています。
# Super Homework Manager
シンプルで高機能な課題管理アプリケーション
[![Go](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![License](https://img.shields.io/badge/License-AGPLv3-blue.svg)](LICENSE.md)
[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white)](#docker-での実行)
</div>
---
## 概要
学生の課題管理を効率化するために設計されたWebアプリケーションです。
繰り返し課題の自動生成やダッシュボードによる期限管理など、日々の課題管理をサポートします。
## スクリーンショット
| ダッシュボード | 課題一覧 | API |
|:---:|:---:|:---:|
| ![ダッシュボード](./docs/images/dashboard.png) | ![課題一覧](./docs/images/list.png) | ![API](./docs/images/api.png) |
## 特徴
- **課題管理**: 課題の登録、編集、削除、完了状況の管理
- **繰り返し課題**: 日次・週次・月次の繰り返し課題を自動生成・管理
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
- **API対応**: 外部連携用のRESTful API (APIキー認証)
- **セキュリティ**:
- CSRF対策
- レート制限 (Rate Limiting)
- セキュアなセッション管理
- **ポータビリティ**: Pure Go SQLiteドライバー使用により、CGO不要でどこでも動作
| 機能 | 説明 |
|---|---|
| **課題管理** | 課題の登録・編集・削除・完了状況の管理 |
| **繰り返し課題** | 日次・週次・月次の繰り返し課題を自動生成 |
| **ダッシュボード** | 期限切れ・本日期限・今週期限の課題をひと目で確認 |
| **REST API** | 外部連携用のAPIキー認証付きRESTful API |
| **セキュリティ** | CSRF対策 / レート制限 / セキュアなセッション管理 / 2FA対応 |
| **ポータビリティ** | Pure Go SQLiteドライバー使用でCGO不要 |
![ダッシュボード](./docs/images/dashboard.png)
![課題一覧](./docs/images/list.png)
![API](./docs/images/api.png)
## クイックスタート
### 前提条件
## TODO
- **Docker Desktop** または Docker / Docker Compose
- (ローカルで直接ビルドする場合のみ)**Go 1.24 以上**
- 取り組み目安時間の登録
- SNS連携もしかしたらやるかも
### 最も簡単な起動方法
## ドキュメント
初めて使う方には、**Dockerを使用した起動**をおすすめします。
詳細な仕様やAPIドキュメントは `docs/` ディレクトリを参照してください
1. このリポジトリをダウンロード(または `git clone`)し、フォルダを開きます
2. フォルダ内にある `config.ini.docker.example` というファイルをコピーし、**名前を `config.ini` に変更**します。
(※ **必須**: この作業を忘れると起動エラーになります)
3. ターミナルまたはコマンドプロンプトやPowerShellでこのフォルダを開き、以下のコマンドを実行します
```bash
docker-compose up -d --build
```
4. ブラウザを開き、**http://localhost** にアクセスしてください。
- [仕様書](docs/SPECIFICATION.md): 機能詳細、データモデル、設定項目
- [APIドキュメント](docs/API.md): APIのエンドポイント、リクエスト/レスポンス形式
> **注意**: 本番環境(外部公開するサーバー上)で使用する場合は、`Caddyfile` の `:80` を実際のドメインに変更し、`config.ini` 内の `[session] secret` 等の安全な文字列への変更を必ず行ってください。
## 前提条件
### ローカルビルド(開発者向け)
- Go 1.24 以上
開発目的で直接実行する場合の手順です。
## インストール方法
```bash
# 1. リポジトリのクローン
git clone <repository-url>
cd Homework-Manager
1. **リポジトリのクローン**
```bash
git clone <repository-url>
cd Homework-Manager
```
# 2. 依存関係のダウンロード
go mod download
2. **依存関係のダウンロード**
```bash
go mod download
```
# 3. ビルド
go build -o homework-manager cmd/server/main.go
3. **アプリケーションのビルド**
```bash
go build -o homework-manager cmd/server/main.go
```
# 4. 設定ファイルの準備
cp config.ini.example config.ini
4. **設定ファイルの準備**
サンプル設定ファイルをコピーして、`config.ini` を作成します。
```bash
cp config.ini.example config.ini
```
※ Windows (PowerShell): `Copy-Item config.ini.example config.ini`
# 5. 実行
./homework-manager
```
**重要**: 本番環境で使用する場合は、必ず `[session] secret` と `[security] csrf_secret` を変更してください。
> **Windows (PowerShell)** の場合:
> `Copy-Item config.ini.example config.ini` → `.\homework-manager.exe`
5. **アプリケーションの実行**
```bash
./homework-manager
```
※ Windows (PowerShell): `.\homework-manager.exe`
ブラウザで **http://localhost:8080** にアクセスしてください。
ブラウザで `http://localhost:8080` にアクセスしてください。
## Dockerでの実行
DockerおよびDocker Composeがインストールされている環境では、以下の手順で簡単に起動できます。
1. **設定ファイルの準備**
```bash
cp config.ini.example config.ini
```
※ 必須です。これを行わないとDockerがディレクトリとして作成してしまい起動に失敗します。
2. **コンテナの起動**
```bash
docker-compose up -d --build
```
3. **アクセスの確認**
ブラウザで `http://localhost:8080` にアクセスしてください。
## 利用時の注意点
1人でSuper Homework Managerを利用する場合は、自分のユーザを登録した後にconfigファイルの[auth]セクションのallow_registrationをfalseに変更し再起動してください。
## 更新方法
1. `git pull` で最新コードを取得
2. `go build -o homework-manager cmd/server/main.go` で再ビルド
3. アプリケーションを再起動
```bash
git pull
go build -o homework-manager cmd/server/main.go
# アプリケーションを再起動
```
## ドキュメント
| ドキュメント | 内容 |
|---|---|
| [仕様書](docs/SPECIFICATION.md) | 機能詳細・データモデル・設定項目 |
| [APIドキュメント](docs/API.md) | エンドポイント・リクエスト/レスポンス形式 |
## TODO
- [ ] 取り組み目安時間の登録
- [ ] SNS連携
## ライセンス
本ソフトウェアのライセンスはAGPLv3 (GNU Affero General Public License v3)です。
詳しくはLICENSEファイルをご覧ください。
[AGPLv3 (GNU Affero General Public License v3)](LICENSE.md)

View File

@@ -22,7 +22,7 @@ secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
allow_registration = true
[security]
https = true
https = false
; こちらも本番環境では必ず変更してください
csrf_secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
rate_limit_enabled = true
@@ -32,3 +32,15 @@ trusted_proxies = 172.16.0.0/12
[notification]
telegram_bot_token =
[captcha]
; CAPTCHAを有効にするか (true/false)
enabled = false
; CAPTCHAの種類: "image"(自前生成)または "turnstile"Cloudflare Turnstile
type = image
; Cloudflare Turnstileを使用する場合typeをturnstileに設定
; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定
; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx
; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@@ -58,3 +58,15 @@ rate_limit_window = 60
; Telegram Bot Token (@BotFatherで取得)
; ユーザーはプロフィール画面でChat IDを設定します
telegram_bot_token =
[captcha]
; CAPTCHAを有効にするか (true/false)
enabled = false
; CAPTCHAの種類: "image"(自前生成)または "turnstile"Cloudflare Turnstile
type = image
; Cloudflare Turnstileを使用する場合typeをturnstileに設定
; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定
; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx
; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@@ -9,8 +9,6 @@ services:
restart: unless-stopped
networks:
- internal
expose:
- "8080"
depends_on:
db:
condition: service_healthy

View File

@@ -40,7 +40,12 @@ Authorization: Bearer hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
| メソッド | パス | 説明 |
|----------|------|------|
| GET | `/api/v1/assignments` | 課題一覧取得 |
| GET | `/api/v1/assignments` | 課題一覧取得(フィルタ・ページネーション対応) |
| GET | `/api/v1/assignments/pending` | 未完了課題一覧取得 |
| GET | `/api/v1/assignments/completed` | 完了済み課題一覧取得 |
| GET | `/api/v1/assignments/overdue` | 期限切れ課題一覧取得 |
| GET | `/api/v1/assignments/due-today` | 本日期限の課題一覧取得 |
| GET | `/api/v1/assignments/due-this-week` | 今週期限の課題一覧取得 |
| GET | `/api/v1/assignments/:id` | 課題詳細取得 |
| POST | `/api/v1/assignments` | 課題作成 |
| PUT | `/api/v1/assignments/:id` | 課題更新 |
@@ -64,7 +69,9 @@ GET /api/v1/assignments
| パラメータ | 型 | 説明 |
|------------|------|------|
| `filter` | string | フィルタ: `pending`, `completed`, `overdue` (省略時: 全件) |
| `filter` | string | フィルタ: `pending`, `completed`, `overdue`省略時: 全件 |
| `page` | integer | ページ番号(デフォルト: `1` |
| `page_size` | integer | 1ページあたりの件数デフォルト: `20`、最大: `100` |
### レスポンス
@@ -79,13 +86,18 @@ GET /api/v1/assignments
"title": "数学レポート",
"description": "第5章の練習問題",
"subject": "数学",
"priority": "medium",
"due_date": "2025-01-15T23:59:00+09:00",
"is_completed": false,
"created_at": "2025-01-10T10:00:00+09:00",
"updated_at": "2025-01-10T10:00:00+09:00"
}
],
"count": 1
"count": 1,
"total_count": 15,
"total_pages": 1,
"current_page": 1,
"page_size": 20
}
```
@@ -95,11 +107,43 @@ GET /api/v1/assignments
# 全件取得
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments
# 未完了のみ取得
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending
# 未完了のみ(ページネーション付き)
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/assignments?filter=pending&page=1&page_size=10"
# 期限切れのみ取得
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue
# 期限切れのみ
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/assignments?filter=overdue"
```
---
## 絞り込み済み課題一覧取得
専用エンドポイントでも同等の絞り込みができます(`pending` / `completed` / `overdue` はページネーション対応)。
```
GET /api/v1/assignments/pending
GET /api/v1/assignments/completed
GET /api/v1/assignments/overdue
GET /api/v1/assignments/due-today
GET /api/v1/assignments/due-this-week
```
### クエリパラメータpending / completed / overdue のみ)
| パラメータ | 型 | 説明 |
|------------|------|------|
| `page` | integer | ページ番号(デフォルト: `1` |
| `page_size` | integer | 1ページあたりの件数デフォルト: `20`、最大: `100` |
### レスポンス
`due-today` / `due-this-week``count` のみ返します(ページネーションなし)。その他は `GET /api/v1/assignments` と同形式です。
### 例
```bash
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/due-today
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/due-this-week
```
---
@@ -127,6 +171,7 @@ GET /api/v1/assignments/:id
"title": "数学レポート",
"description": "第5章の練習問題",
"subject": "数学",
"priority": "medium",
"due_date": "2025-01-15T23:59:00+09:00",
"is_completed": false,
"created_at": "2025-01-10T10:00:00+09:00",
@@ -163,29 +208,30 @@ POST /api/v1/assignments
| `title` | string | ✅ | 課題タイトル |
| `description` | string | | 説明 |
| `subject` | string | | 教科・科目 |
| `priority` | string | | 重要度: `low`, `medium`, `high`(デフォルト: `medium` |
| `due_date` | string | ✅ | 提出期限RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD` |
| `reminder_enabled` | boolean | | リマインダーを有効にするか(省略時: false |
| `reminder_at` | string | | リマインダー設定時刻形式はdue_dateと同じ |
| `urgent_reminder_enabled` | boolean | | 期限切れ時の督促リマインダーを有効にするか(省略時: true |
| `recurrence` | object | | 繰り返し設定(下参照) |
| `reminder_enabled` | boolean | | リマインダーを有効にするか(デフォルト: `false` |
| `reminder_at` | string | | リマインダー設定時刻(形式は `due_date` と同じ) |
| `urgent_reminder_enabled` | boolean | | 督促リマインダーを有効にするか(デフォルト: `true` |
| `recurrence` | object | | 繰り返し設定(下参照) |
### Recurrence オブジェクト
| フィールド | 型 | 説明 |
|------------|------|------|
| `type` | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`, または空文字で無効) |
| `interval` | integer | 間隔 (例: 1 = 毎週, 2 = 隔週) |
| `weekday` | integer | 週次の曜日 (0=日, 1=月, ..., 6=土) |
| `day` | integer | 月次の日付 (1-31) |
| `type` | string | 繰り返しタイプ: `daily`, `weekly`, `monthly`(空文字で繰り返しなし) |
| `interval` | integer | 繰り返し間隔(例: `1` = 毎週、`2` = 隔週 |
| `weekday` | integer | 週次の曜日`0`=日, `1`=月, ..., `6`=土 |
| `day` | integer | 月次の日付1-31 |
| `until` | object | 終了条件 |
#### Recurrence.Until オブジェクト
| フィールド | 型 | 説明 |
|------------|------|------|
| `type` | string | 終了タイプ (`never`, `count`, `date`) |
| `count` | integer | 回数指定時の終了回数 |
| `date` | string | 日付指定時の終了日 |
| `type` | string | 終了タイプ: `never`, `count`, `date` |
| `count` | integer | 終了回数`count` 指定時) |
| `date` | string | 終了日`date` 指定時) |
### リクエスト例
@@ -194,6 +240,7 @@ POST /api/v1/assignments
"title": "英語エッセイ",
"description": "テーマ自由、1000語以上",
"subject": "英語",
"priority": "high",
"due_date": "2025-01-20T17:00"
}
```
@@ -209,6 +256,7 @@ POST /api/v1/assignments
"title": "英語エッセイ",
"description": "テーマ自由、1000語以上",
"subject": "英語",
"priority": "high",
"due_date": "2025-01-20T17:00:00+09:00",
"is_completed": false,
"created_at": "2025-01-10T11:00:00+09:00",
@@ -216,11 +264,20 @@ POST /api/v1/assignments
}
```
繰り返し設定を含む場合は `recurring_assignment` を返します:
```json
{
"message": "Recurring assignment created",
"recurring_assignment": { ... }
}
```
**400 Bad Request**
```json
{
"error": "Invalid input: title and due_date are required"
"error": "Invalid input: Key: 'title' Error:Field validation for 'title' failed on the 'required' tag"
}
```
@@ -230,7 +287,7 @@ POST /api/v1/assignments
curl -X POST \
-H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \
-d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \
-d '{"title":"英語エッセイ","subject":"英語","due_date":"2025-01-20"}' \
http://localhost:8080/api/v1/assignments
```
@@ -257,6 +314,7 @@ PUT /api/v1/assignments/:id
| `title` | string | 課題タイトル |
| `description` | string | 説明 |
| `subject` | string | 教科・科目 |
| `priority` | string | 重要度: `low`, `medium`, `high` |
| `due_date` | string | 提出期限 |
| `reminder_enabled` | boolean | リマインダー有効/無効 |
| `reminder_at` | string | リマインダー時刻 |
@@ -273,21 +331,7 @@ PUT /api/v1/assignments/:id
### レスポンス
**200 OK**
```json
{
"id": 2,
"user_id": 1,
"title": "英語エッセイ(修正版)",
"description": "テーマ自由、1000語以上",
"subject": "英語",
"due_date": "2025-01-25T17:00:00+09:00",
"is_completed": false,
"created_at": "2025-01-10T11:00:00+09:00",
"updated_at": "2025-01-10T12:00:00+09:00"
}
```
**200 OK** — 更新後の課題オブジェクト
**404 Not Found**
@@ -332,25 +376,33 @@ DELETE /api/v1/assignments/:id
**200 OK**
```json
{
"message": "Assignment deleted"
}
{ "message": "Assignment deleted" }
```
繰り返し設定も削除した場合:
```json
{ "message": "Assignment and recurring settings deleted" }
```
**404 Not Found**
```json
{
"error": "Assignment not found"
}
{ "error": "Assignment not found" }
```
### 例
```bash
# 課題のみ削除
curl -X DELETE \
-H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/assignments/2
# 課題と繰り返し設定をまとめて削除
curl -X DELETE \
-H "Authorization: Bearer hm_xxx" \
"http://localhost:8080/api/v1/assignments/2?delete_recurring=true"
```
---
@@ -380,6 +432,7 @@ PATCH /api/v1/assignments/:id/toggle
"title": "数学レポート",
"description": "第5章の練習問題",
"subject": "数学",
"priority": "medium",
"due_date": "2025-01-15T23:59:00+09:00",
"is_completed": true,
"completed_at": "2025-01-12T14:30:00+09:00",
@@ -391,9 +444,7 @@ PATCH /api/v1/assignments/:id/toggle
**404 Not Found**
```json
{
"error": "Assignment not found"
}
{ "error": "Assignment not found" }
```
### 例
@@ -408,7 +459,7 @@ curl -X PATCH \
## 統計情報取得
ユーザーの課題統計を取得します。科目、日付範囲でフィルタリング可能です。
ユーザーの課題統計を取得します。
```
GET /api/v1/statistics
@@ -419,8 +470,9 @@ GET /api/v1/statistics
| パラメータ | 型 | 説明 |
|------------|------|------|
| `subject` | string | 科目で絞り込み(省略時: 全科目) |
| `from` | string | 課題登録日の開始日YYYY-MM-DD形式 |
| `to` | string | 課題登録日の終了日YYYY-MM-DD形式 |
| `from` | string | 課題登録日の開始日(`YYYY-MM-DD` |
| `to` | string | 課題登録日の終了日(`YYYY-MM-DD` |
| `include_archived` | boolean | アーカイブ済み課題を含む(デフォルト: `false` |
### レスポンス
@@ -446,36 +498,11 @@ GET /api/v1/statistics
"pending": 2,
"overdue": 1,
"on_time_completion_rate": 91.7
},
{
"subject": "英語",
"total": 10,
"completed": 8,
"pending": 2,
"overdue": 0,
"on_time_completion_rate": 87.5
}
]
}
```
### 科目別統計 (特定科目のみ)
```json
{
"total_assignments": 15,
"completed_assignments": 12,
"pending_assignments": 2,
"overdue_assignments": 1,
"on_time_completion_rate": 91.7,
"filter": {
"subject": "数学",
"from": null,
"to": null
}
}
```
### 例
```bash
@@ -487,9 +514,175 @@ curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?
# 日付範囲で絞り込み
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31"
```
# 科目と日付範囲の組み合わせ
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31"
---
## 繰り返し設定一覧取得
```
GET /api/v1/recurring
```
### レスポンス
**200 OK**
```json
{
"recurring_assignments": [
{
"id": 1,
"user_id": 1,
"title": "週次ミーティング",
"subject": "その他",
"priority": "medium",
"recurrence_type": "weekly",
"recurrence_interval": 1,
"recurrence_weekday": 1,
"due_time": "23:59",
"end_type": "never",
"is_active": true,
"created_at": "2025-01-01T00:00:00+09:00",
"updated_at": "2025-01-01T00:00:00+09:00"
}
],
"count": 1
}
```
---
## 繰り返し設定詳細取得
```
GET /api/v1/recurring/:id
```
### パスパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `id` | integer | 繰り返し設定ID |
### レスポンス
**200 OK** — 繰り返し設定オブジェクト(一覧と同形式)
**404 Not Found**
```json
{ "error": "Recurring assignment not found" }
```
---
## 繰り返し設定更新
```
PUT /api/v1/recurring/:id
```
### パスパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `id` | integer | 繰り返し設定ID |
### リクエストボディ
すべてのフィールドはオプションです。省略されたフィールドは既存の値を維持します。
| フィールド | 型 | 説明 |
|------------|------|------|
| `title` | string | タイトル |
| `description` | string | 説明 |
| `subject` | string | 教科・科目 |
| `priority` | string | 重要度: `low`, `medium`, `high` |
| `recurrence_type` | string | 繰り返しタイプ: `daily`, `weekly`, `monthly` |
| `recurrence_interval` | integer | 繰り返し間隔 |
| `recurrence_weekday` | integer | 週次の曜日0-6 |
| `recurrence_day` | integer | 月次の日付1-31 |
| `due_time` | string | 締切時刻(`HH:MM` |
| `end_type` | string | 終了タイプ: `never`, `count`, `date` |
| `end_count` | integer | 終了回数 |
| `end_date` | string | 終了日(`YYYY-MM-DD` |
| `is_active` | boolean | `false` で停止、`true` で再開 |
| `reminder_enabled` | boolean | リマインダー有効/無効 |
| `reminder_offset` | integer | リマインダーのオフセット(分) |
| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 |
| `edit_behavior` | string | 編集範囲: `this_only`, `this_and_future`, `all`(デフォルト: `this_only` |
### リクエスト例(一時停止)
```json
{
"is_active": false
}
```
### レスポンス
**200 OK** — 更新後の繰り返し設定オブジェクト
**404 Not Found**
```json
{ "error": "Recurring assignment not found" }
```
### 例
```bash
# 一時停止
curl -X PUT \
-H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \
-d '{"is_active": false}' \
http://localhost:8080/api/v1/recurring/1
# タイトルと締切時刻を変更
curl -X PUT \
-H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \
-d '{"title":"更新済みタスク","due_time":"22:00"}' \
http://localhost:8080/api/v1/recurring/1
```
---
## 繰り返し設定削除
```
DELETE /api/v1/recurring/:id
```
### パスパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `id` | integer | 繰り返し設定ID |
### レスポンス
**200 OK**
```json
{ "message": "Recurring assignment deleted" }
```
**404 Not Found**
```json
{ "error": "Recurring assignment not found or failed to delete" }
```
### 例
```bash
curl -X DELETE \
-H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/recurring/1
```
---
@@ -511,6 +704,7 @@ curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?
| 400 Bad Request | リクエストの形式が不正 |
| 401 Unauthorized | 認証エラー |
| 404 Not Found | リソースが見つからない |
| 429 Too Many Requests | レート制限超過 |
| 500 Internal Server Error | サーバー内部エラー |
---
@@ -521,7 +715,7 @@ APIは以下の日付形式を受け付けます優先度順
1. **RFC3339**: `2025-01-15T23:59:00+09:00`
2. **日時形式**: `2025-01-15T23:59`
3. **日付のみ**: `2025-01-15`時刻は23:59に設定
3. **日付のみ**: `2025-01-15`(時刻は `23:59` に設定)
レスポンスの日付はすべてRFC3339形式で返されます。
@@ -542,83 +736,3 @@ APIは以下の日付形式を受け付けます優先度順
```
設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。
---
## 繰り返し設定一覧取得
```
GET /api/v1/recurring
```
### レスポンス
**200 OK**
```json
{
"recurring_assignments": [
{
"id": 1,
"user_id": 1,
"title": "週次ミーティング",
"recurrence_type": "weekly",
"interval": 1,
"weekday": 1,
"is_active": true
}
],
"count": 1
}
```
---
## 繰り返し設定詳細取得
```
GET /api/v1/recurring/:id
```
### レスポンス
**200 OK**
---
## 繰り返し設定更新
```
PUT /api/v1/recurring/:id
```
### リクエストボディ
各フィールドはオプション。省略時は更新なし。
| フィールド | 型 | 説明 |
|------------|------|------|
| `title` | string | タイトル |
| `is_active` | boolean | `false` で停止、`true` で再開 |
| `recurrence_type` | string | `daily`, `weekly`, `monthly` |
| ... | ... | その他の設定フィールド |
### リクエスト例(停止)
```json
{
"is_active": false
}
```
---
## 繰り返し設定削除
```
DELETE /api/v1/recurring/:id
```
### レスポンス
**200 OK**

View File

@@ -8,9 +8,9 @@ Super Homework Managerは、学生の課題管理を支援するWebアプリケ
| 項目 | 技術 |
|------|------|
| 言語 | Go |
| 言語 | Go 1.24+ |
| Webフレームワーク | Gin |
| データベース | SQLite (GORM with Pure Go driver - glebarez/sqlite) |
| データベース | SQLite / MySQL / PostgreSQL (GORM、Pure Go SQLiteドライバー使用) |
| セッション管理 | gin-contrib/sessions (Cookie store) |
| テンプレートエンジン | Go html/template |
| コンテナ | Docker対応 |
@@ -27,7 +27,8 @@ homework-manager/
│ ├── middleware/ # ミドルウェア
│ ├── models/ # データモデル
│ ├── repository/ # データアクセス層
── service/ # ビジネスロジック
── service/ # ビジネスロジック
│ └── validation/ # 入力バリデーション
├── web/
│ ├── static/ # 静的ファイル (CSS, JS)
│ └── templates/ # HTMLテンプレート
@@ -50,6 +51,8 @@ homework-manager/
| PasswordHash | string | パスワードハッシュ | Not Null |
| Name | string | 表示名 | Not Null |
| Role | string | 権限 (`user` or `admin`) | Default: `user` |
| TOTPSecret | string | TOTP秘密鍵 | - |
| TOTPEnabled | bool | 2FA有効フラグ | Default: false |
| CreatedAt | time.Time | 作成日時 | 自動設定 |
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
@@ -115,8 +118,7 @@ homework-manager/
| UserID | uint | ユーザーID | Unique, Not Null |
| TelegramEnabled | bool | Telegram通知 | Default: false |
| TelegramChatID | string | Telegram Chat ID | - |
| LineEnabled | bool | LINE通知 | Default: false |
| LineNotifyToken | string | LINE Notifyトークン | - |
| NotifyOnCreate | bool | 課題追加時に通知 | Default: true |
| CreatedAt | time.Time | 作成日時 | 自動設定 |
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
@@ -146,6 +148,7 @@ REST API認証用のAPIキーを管理するモデル。
- **パスワード要件**: 8文字以上
- **パスワードハッシュ**: bcryptを使用
- **CSRF対策**: 全フォームでのトークン検証
- **2段階認証 (TOTP)**: プロフィール画面からGoogle Authenticator等で設定可能。有効化後はログイン時にワンタイムパスワードの入力が必要
### 3.2 API認証
@@ -159,6 +162,7 @@ REST API認証用のAPIキーを管理するモデル。
|--------|------|
| `user` | 自分の課題のCRUD操作、プロフィール管理 |
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
---
@@ -170,8 +174,9 @@ REST API認証用のAPIキーを管理するモデル。
| 機能 | 説明 |
|------|------|
| 新規登録 | メールアドレス、パスワード、名前で登録 |
| ログイン | メールアドレスとパスワードでログイン |
| ログイン | メールアドレスとパスワードでログイン。2FA有効時は続けてTOTPコードを入力 |
| ログアウト | セッションをクリアしてログアウト |
| CAPTCHA | ログイン・登録フォームへのbot対策画像認証またはCloudflare Turnstile |
### 4.2 課題管理機能
@@ -207,7 +212,7 @@ REST API認証用のAPIキーを管理するモデル。
| 項目 | 説明 |
|------|------|
| 設定 | 課題登録・編集画面で通知日時を指定 |
| 送信 | 指定日時にTelegram/LINEで通知 |
| 送信 | 指定日時にTelegramで通知 |
#### 4.4.2 督促通知
@@ -226,7 +231,6 @@ REST API認証用のAPIキーを管理するモデル。
| チャンネル | 設定方法 |
|------------|----------|
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
| LINE Notify | プロフィールでアクセストークン入力 |
### 4.5 プロフィール機能
@@ -235,7 +239,9 @@ REST API認証用のAPIキーを管理するモデル。
| プロフィール表示 | ユーザー情報を表示 |
| プロフィール更新 | 表示名を変更 |
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
| 通知設定 | Telegram/LINE通知の有効化とトークン設定 |
| 通知設定 | Telegram通知の有効化とChat ID設定 |
| 2FA設定 | TOTPアプリGoogle Authenticator等でQRコードをスキャンし2FAを有効化 |
| 2FA無効化 | 有効中の2FAを無効化 |
### 4.6 管理者機能
@@ -279,7 +285,14 @@ rate_limit_requests = 100
rate_limit_window = 60
[notification]
telegram_bot_token = your-telegram-bot-token
telegram_bot_token = your-telegram-bot-token
[captcha]
enabled = false
type = image
# type = turnstile の場合は以下も設定
# turnstile_site_key = your-site-key
# turnstile_secret_key = your-secret-key
```
### 5.2 設定項目
@@ -288,16 +301,26 @@ rate_limit_window = 60
|------------|------|------|--------------|
| `server` | `port` | サーバーポート | `8080` |
| `server` | `debug` | デバッグモード | `true` |
| `database` | `driver` | DBドライバー (sqlite, mysql, postgres) | `sqlite` |
| `database` | `driver` | DBドライバー (`sqlite`, `mysql`, `postgres`) | `sqlite` |
| `database` | `path` | SQLiteファイルパス | `homework.db` |
| `session` | `secret` | セッション暗号化キー | (必須) |
| `database` | `host` | DBホスト (MySQL/PostgreSQL) | `localhost` |
| `database` | `port` | DBポート (MySQL/PostgreSQL) | `3306` |
| `database` | `user` | DBユーザー (MySQL/PostgreSQL) | `root` |
| `database` | `password` | DBパスワード (MySQL/PostgreSQL) | - |
| `database` | `name` | DB名 (MySQL/PostgreSQL) | `homework_manager` |
| `session` | `secret` | セッション暗号化キー | **(必須)** |
| `auth` | `allow_registration` | 新規登録許可 | `true` |
| `security` | `https` | HTTPS設定(Secure Cookie) | `false` |
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | (必須) |
| `security` | `https` | HTTPS設定 (Secure Cookie) | `false` |
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | **(必須)** |
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
| `security` | `rate_limit_window` | 期間(秒) | `60` |
| `security` | `trusted_proxies` | 信頼するプロキシ | - |
| `notification` | `telegram_bot_token` | Telegram Bot Token | - |
| `captcha` | `enabled` | CAPTCHA有効化 | `false` |
| `captcha` | `type` | CAPTCHAタイプ (`image` or `turnstile`) | `image` |
| `captcha` | `turnstile_site_key` | Cloudflare Turnstile サイトキー | - |
| `captcha` | `turnstile_secret_key` | Cloudflare Turnstile シークレットキー | - |
### 5.3 環境変数
@@ -308,13 +331,22 @@ rate_limit_window = 60
| `PORT` | サーバーポート |
| `DATABASE_DRIVER` | データベースドライバー |
| `DATABASE_PATH` | SQLiteデータベースファイルパス |
| `DATABASE_HOST` | DBホスト |
| `DATABASE_PORT` | DBポート |
| `DATABASE_USER` | DBユーザー |
| `DATABASE_PASSWORD` | DBパスワード |
| `DATABASE_NAME` | DB名 |
| `SESSION_SECRET` | セッション暗号化キー |
| `CSRF_SECRET` | CSRFトークン秘密鍵 |
| `GIN_MODE` | `release` でリリースモードdebug=false |
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
| `HTTPS` | HTTPSモード (true/false) |
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
| `GIN_MODE` | `release` でリリースモード |
| `ALLOW_REGISTRATION` | 新規登録許可 (`true`/`false`) |
| `HTTPS` | HTTPSモード (`true`/`false`) |
| `TRUSTED_PROXIES` | 信頼するプロキシ |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
| `CAPTCHA_ENABLED` | CAPTCHA有効化 (`true`/`false`) |
| `CAPTCHA_TYPE` | CAPTCHAタイプ (`image`/`turnstile`) |
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile サイトキー |
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile シークレットキー |
### 5.4 設定の優先順位
@@ -329,13 +361,16 @@ rate_limit_window = 60
### 6.1 実装済みセキュリティ機能
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
- **2段階認証 (TOTP)**: RFC 6238準拠のTOTPによる2FA
- **CAPTCHA**: ログイン・登録時のbot対策画像認証またはCloudflare Turnstile
- **セッションセキュリティ**: HttpOnly Cookie
- **入力バリデーション**: 各ハンドラで基本的な入力検証
- **CSFR対策**: Double Submit Cookieパターンまたは同期トークンによるCSRF保護
- **CSRF対策**: Double Submit Cookieパターンによる全フォーム保護
- **レート制限**: IPベースのリクエスト制限によるDoS対策
- **論理削除**: データの完全削除を防ぐソフトデリート
- **権限チェック**: ミドルウェアによるロールベースアクセス制御
- **Secure Cookie**: HTTPS設定時のSecure属性付与
- **セキュリティヘッダー**: X-Content-Type-Options, X-Frame-Options等の設定
### 6.2 推奨される本番環境設定
@@ -343,6 +378,7 @@ rate_limit_window = 60
- HTTPSを有効化し、`HTTPS=true` を設定
- `GIN_MODE=release` を設定
- 必要に応じて `TRUSTED_PROXIES` を設定
- `CAPTCHA_ENABLED=true` を設定してbot対策を強化
---

3
go.mod
View File

@@ -17,10 +17,12 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dchest/captcha v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -49,6 +51,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
@@ -11,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
@@ -87,6 +91,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=

View File

@@ -21,6 +21,13 @@ type NotificationConfig struct {
TelegramBotToken string
}
type CaptchaConfig struct {
Enabled bool
Type string // "turnstile" or "image"
TurnstileSiteKey string
TurnstileSecretKey string
}
type Config struct {
Port string
SessionSecret string
@@ -34,6 +41,7 @@ type Config struct {
TrustedProxies []string
Database DatabaseConfig
Notification NotificationConfig
Captcha CaptchaConfig
}
func Load(configPath string) *Config {
@@ -56,6 +64,10 @@ func Load(configPath string) *Config {
Password: "",
Name: "homework_manager",
},
Captcha: CaptchaConfig{
Enabled: false,
Type: "image",
},
}
if configPath == "" {
@@ -134,6 +146,21 @@ func Load(configPath string) *Config {
if section.HasKey("telegram_bot_token") {
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
}
// Captcha section
section = iniFile.Section("captcha")
if section.HasKey("enabled") {
cfg.Captcha.Enabled = section.Key("enabled").MustBool(false)
}
if section.HasKey("type") {
cfg.Captcha.Type = section.Key("type").String()
}
if section.HasKey("turnstile_site_key") {
cfg.Captcha.TurnstileSiteKey = section.Key("turnstile_site_key").String()
}
if section.HasKey("turnstile_secret_key") {
cfg.Captcha.TurnstileSecretKey = section.Key("turnstile_secret_key").String()
}
} else {
log.Println("config.ini not found, using environment variables or defaults")
}
@@ -183,6 +210,18 @@ func Load(configPath string) *Config {
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
cfg.Notification.TelegramBotToken = telegramToken
}
if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" {
cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1"
}
if captchaType := os.Getenv("CAPTCHA_TYPE"); captchaType != "" {
cfg.Captcha.Type = captchaType
}
if turnstileSiteKey := os.Getenv("TURNSTILE_SITE_KEY"); turnstileSiteKey != "" {
cfg.Captcha.TurnstileSiteKey = turnstileSiteKey
}
if turnstileSecretKey := os.Getenv("TURNSTILE_SECRET_KEY"); turnstileSecretKey != "" {
cfg.Captcha.TurnstileSecretKey = turnstileSecretKey
}
if cfg.SessionSecret == "" {
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")

View File

@@ -7,6 +7,7 @@ import (
"homework-manager/internal/middleware"
"homework-manager/internal/service"
"homework-manager/internal/validation"
"github.com/gin-gonic/gin"
)
@@ -264,6 +265,11 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
return
}
if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dueDate, err := parseDateString(input.DueDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
@@ -386,6 +392,11 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
return
}
if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
title := input.Title
if title == "" {
title = existing.Title

View File

@@ -9,6 +9,7 @@ import (
"homework-manager/internal/middleware"
"homework-manager/internal/models"
"homework-manager/internal/service"
"homework-manager/internal/validation"
"github.com/gin-gonic/gin"
)
@@ -126,6 +127,22 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
priority := c.PostForm("priority")
dueDateStr := c.PostForm("due_date")
if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil {
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
"title": "課題登録",
"error": err.Error(),
"formTitle": title,
"description": description,
"subject": subject,
"priority": priority,
"isAdmin": role == "admin",
"userName": name,
})
return
}
reminderEnabled := c.PostForm("reminder_enabled") == "on"
reminderAtStr := c.PostForm("reminder_at")
var reminderAt *time.Time
@@ -298,6 +315,11 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
priority := c.PostForm("priority")
dueDateStr := c.PostForm("due_date")
if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
reminderEnabled := c.PostForm("reminder_enabled") == "on"
reminderAtStr := c.PostForm("reminder_at")
var reminderAt *time.Time

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"homework-manager/internal/config"
"homework-manager/internal/middleware"
"homework-manager/internal/service"
@@ -10,33 +11,96 @@ import (
"github.com/gin-gonic/gin"
)
const twoFAPendingKey = "2fa_pending_user_id"
type AuthHandler struct {
authService *service.AuthService
authService *service.AuthService
totpService *service.TOTPService
captchaService *service.CaptchaService
captchaCfg config.CaptchaConfig
}
func NewAuthHandler() *AuthHandler {
func NewAuthHandler(captchaCfg config.CaptchaConfig) *AuthHandler {
captchaSvc := service.NewCaptchaService(captchaCfg.Type, captchaCfg.TurnstileSecretKey)
return &AuthHandler{
authService: service.NewAuthService(),
authService: service.NewAuthService(),
totpService: service.NewTOTPService(),
captchaService: captchaSvc,
captchaCfg: captchaCfg,
}
}
func (h *AuthHandler) captchaData() gin.H {
data := gin.H{
"captchaEnabled": h.captchaCfg.Enabled,
"captchaType": h.captchaCfg.Type,
}
if h.captchaCfg.Enabled && h.captchaCfg.Type == "turnstile" {
data["turnstileSiteKey"] = h.captchaCfg.TurnstileSiteKey
}
if h.captchaCfg.Enabled && h.captchaCfg.Type == "image" {
data["captchaID"] = h.captchaService.NewImageCaptcha()
}
return data
}
func (h *AuthHandler) verifyCaptcha(c *gin.Context) bool {
if !h.captchaCfg.Enabled {
return true
}
switch h.captchaCfg.Type {
case "turnstile":
token := c.PostForm("cf-turnstile-response")
ok, err := h.captchaService.VerifyTurnstile(token, c.ClientIP())
return err == nil && ok
case "image":
id := c.PostForm("captcha_id")
answer := c.PostForm("captcha_answer")
return h.captchaService.VerifyImageCaptcha(id, answer)
}
return true
}
func (h *AuthHandler) ShowLogin(c *gin.Context) {
RenderHTML(c, http.StatusOK, "login.html", gin.H{
"title": "ログイン",
})
data := gin.H{"title": "ログイン"}
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "login.html", data)
}
func (h *AuthHandler) Login(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
renderLoginError := func(msg string) {
data := gin.H{
"title": "ログイン",
"error": msg,
"email": email,
}
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "login.html", data)
}
if !h.verifyCaptcha(c) {
renderLoginError("CAPTCHAの検証に失敗しました。もう一度お試しください")
return
}
user, err := h.authService.Login(email, password)
if err != nil {
RenderHTML(c, http.StatusOK, "login.html", gin.H{
"title": "ログイン",
"error": "メールアドレスまたはパスワードが正しくありません",
"email": email,
})
renderLoginError("メールアドレスまたはパスワードが正しくありません")
return
}
if user.TOTPEnabled {
session := sessions.Default(c)
session.Set(twoFAPendingKey, user.ID)
session.Save()
c.Redirect(http.StatusFound, "/login/2fa")
return
}
@@ -49,35 +113,91 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
}
func (h *AuthHandler) ShowRegister(c *gin.Context) {
RenderHTML(c, http.StatusOK, "register.html", gin.H{
"title": "新規登録",
func (h *AuthHandler) ShowLogin2FA(c *gin.Context) {
session := sessions.Default(c)
if session.Get(twoFAPendingKey) == nil {
c.Redirect(http.StatusFound, "/login")
return
}
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
"title": "2段階認証",
})
}
func (h *AuthHandler) Login2FA(c *gin.Context) {
session := sessions.Default(c)
pendingID := session.Get(twoFAPendingKey)
if pendingID == nil {
c.Redirect(http.StatusFound, "/login")
return
}
userID := pendingID.(uint)
user, err := h.authService.GetUserByID(userID)
if err != nil {
session.Delete(twoFAPendingKey)
session.Save()
c.Redirect(http.StatusFound, "/login")
return
}
code := c.PostForm("totp_code")
if !h.totpService.Validate(user.TOTPSecret, code) {
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
"title": "2段階認証",
"error": "認証コードが正しくありません",
})
return
}
session.Delete(twoFAPendingKey)
session.Set(middleware.UserIDKey, user.ID)
session.Set(middleware.UserRoleKey, user.Role)
session.Set(middleware.UserNameKey, user.Name)
session.Save()
c.Redirect(http.StatusFound, "/")
}
func (h *AuthHandler) ShowRegister(c *gin.Context) {
data := gin.H{"title": "新規登録"}
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "register.html", data)
}
func (h *AuthHandler) Register(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
passwordConfirm := c.PostForm("password_confirm")
name := c.PostForm("name")
if password != passwordConfirm {
RenderHTML(c, http.StatusOK, "register.html", gin.H{
renderRegisterError := func(msg string) {
data := gin.H{
"title": "新規登録",
"error": "パスワードが一致しません",
"error": msg,
"email": email,
"name": name,
})
}
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "register.html", data)
}
if !h.verifyCaptcha(c) {
renderRegisterError("CAPTCHAの検証に失敗しました。もう一度お試しください")
return
}
if password != passwordConfirm {
renderRegisterError("パスワードが一致しません")
return
}
if len(password) < 8 {
RenderHTML(c, http.StatusOK, "register.html", gin.H{
"title": "新規登録",
"error": "パスワードは8文字以上で入力してください",
"email": email,
"name": name,
})
renderRegisterError("パスワードは8文字以上で入力してください")
return
}
@@ -87,12 +207,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
if err == service.ErrEmailAlreadyExists {
errorMsg = "このメールアドレスは既に使用されています"
}
RenderHTML(c, http.StatusOK, "register.html", gin.H{
"title": "新規登録",
"error": errorMsg,
"email": email,
"name": name,
})
renderRegisterError(errorMsg)
return
}

View File

@@ -7,18 +7,23 @@ import (
"homework-manager/internal/models"
"homework-manager/internal/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type ProfileHandler struct {
authService *service.AuthService
totpService *service.TOTPService
notificationService *service.NotificationService
appName string
}
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
return &ProfileHandler{
authService: service.NewAuthService(),
totpService: service.NewTOTPService(),
notificationService: notificationService,
appName: "Super-HomeworkManager",
}
}
@@ -143,8 +148,6 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
settings := &models.UserNotificationSettings{
TelegramEnabled: c.PostForm("telegram_enabled") == "on",
TelegramChatID: c.PostForm("telegram_chat_id"),
LineEnabled: c.PostForm("line_enabled") == "on",
LineNotifyToken: c.PostForm("line_token"),
NotifyOnCreate: c.PostForm("notify_on_create") == "on",
}
@@ -173,3 +176,135 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
"notifySettings": notifySettings,
})
}
const totpPendingSecretKey = "totp_pending_secret"
func (h *ProfileHandler) ShowTOTPSetup(c *gin.Context) {
userID := h.getUserID(c)
user, _ := h.authService.GetUserByID(userID)
setupData, err := h.totpService.GenerateSecret(user.Email, h.appName)
if err != nil {
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
"title": "2段階認証の設定",
"error": "シークレットの生成に失敗しました",
})
return
}
session := sessions.Default(c)
session.Set(totpPendingSecretKey, setupData.Secret)
session.Save()
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
"title": "2段階認証の設定",
"secret": setupData.Secret,
"qrCode": setupData.QRCodeB64,
"otpAuthURL": setupData.OTPAuthURL,
})
}
func (h *ProfileHandler) EnableTOTP(c *gin.Context) {
userID := h.getUserID(c)
user, _ := h.authService.GetUserByID(userID)
session := sessions.Default(c)
secret, ok := session.Get(totpPendingSecretKey).(string)
if !ok || secret == "" {
c.Redirect(http.StatusFound, "/profile/totp/setup")
return
}
renderSetupError := func(msg string) {
data := gin.H{
"title": "2段階認証の設定",
"error": msg,
"secret": secret,
}
if setupData, err := h.totpService.SetupDataFromSecret(secret, user.Email, h.appName); err == nil {
data["qrCode"] = setupData.QRCodeB64
data["otpAuthURL"] = setupData.OTPAuthURL
}
RenderHTML(c, http.StatusOK, "totp_setup.html", data)
}
password := c.PostForm("password")
if _, err := h.authService.Login(user.Email, password); err != nil {
renderSetupError("パスワードが正しくありません")
return
}
code := c.PostForm("totp_code")
if !h.totpService.Validate(secret, code) {
renderSetupError("認証コードが正しくありません。もう一度試してください")
return
}
if err := h.authService.EnableTOTP(userID, secret); err != nil {
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
"title": "2段階認証の設定",
"error": "2段階認証の有効化に失敗しました",
})
return
}
session.Delete(totpPendingSecretKey)
session.Save()
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
notifySettings, _ := h.notificationService.GetUserSettings(userID)
user, _ = h.authService.GetUserByID(userID)
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpSuccess": "2段階認証を有効化しました",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
}
func (h *ProfileHandler) DisableTOTP(c *gin.Context) {
userID := h.getUserID(c)
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
user, _ := h.authService.GetUserByID(userID)
notifySettings, _ := h.notificationService.GetUserSettings(userID)
password := c.PostForm("password")
if _, err := h.authService.Login(user.Email, password); err != nil {
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpError": "パスワードが正しくありません",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
return
}
if err := h.authService.DisableTOTP(userID); err != nil {
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpError": "2段階認証の無効化に失敗しました",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
return
}
user, _ = h.authService.GetUserByID(userID)
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpSuccess": "2段階認証を無効化しました",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
}

View File

@@ -101,12 +101,6 @@ func CSRF(config CSRFConfig) gin.HandlerFunc {
}
c.Next()
newToken, err := generateCSRFToken(config.Secret)
if err == nil {
session.Set(csrfTokenKey, newToken)
session.Save()
}
}
}

View File

@@ -7,21 +7,33 @@ import (
)
type SecurityConfig struct {
HTTPS bool
HTTPS bool
TurnstileEnabled bool
}
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
return func(c *gin.Context) {
if config.HTTPS {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
scriptSrc := "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
frameSrc := "'none'"
connectSrc := "'self'"
if config.TurnstileEnabled {
scriptSrc += " https://challenges.cloudflare.com"
frameSrc = "https://challenges.cloudflare.com"
connectSrc += " https://challenges.cloudflare.com"
}
csp := []string{
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
"script-src " + scriptSrc,
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
"font-src 'self' https://cdn.jsdelivr.net",
"img-src 'self' data:",
"connect-src 'self'",
"connect-src " + connectSrc,
"frame-src " + frameSrc,
"frame-ancestors 'none'",
}
c.Header("Content-Security-Policy", strings.Join(csp, "; "))

View File

@@ -7,16 +7,15 @@ import (
)
type UserNotificationSettings struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
TelegramChatID string `json:"telegram_chat_id"`
LineEnabled bool `gorm:"default:false" json:"line_enabled"`
LineNotifyToken string `json:"-"`
NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
TelegramChatID string `json:"telegram_chat_id"`
NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User *User `gorm:"foreignKey:UserID" json:"-"`
}

View File

@@ -12,6 +12,8 @@ type User struct {
PasswordHash string `gorm:"not null" json:"-"`
Name string `gorm:"not null" json:"name"`
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
TOTPSecret string `gorm:"size:100" json:"-"`
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

@@ -76,7 +76,10 @@ func (r *RecurringAssignmentRepository) FindDueForGeneration() ([]models.Recurri
}
}
if shouldGenerate {
if !shouldGenerate {
rec.IsActive = false
r.db.Save(&rec)
} else {
result = append(result, rec)
}
}

View File

@@ -14,6 +14,7 @@ import (
"homework-manager/internal/middleware"
"homework-manager/internal/service"
"github.com/dchest/captcha"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
@@ -175,7 +176,8 @@ func Setup(cfg *config.Config) *gin.Engine {
r.Use(middleware.RequestTimer())
securityConfig := middleware.SecurityConfig{
HTTPS: cfg.HTTPS,
HTTPS: cfg.HTTPS,
TurnstileEnabled: cfg.Captcha.Enabled && cfg.Captcha.Type == "turnstile",
}
r.Use(middleware.SecurityHeaders(securityConfig))
r.Use(middleware.ForceHTTPS(securityConfig))
@@ -196,13 +198,22 @@ func Setup(cfg *config.Config) *gin.Engine {
notificationService.StartReminderScheduler()
authHandler := handler.NewAuthHandler()
authHandler := handler.NewAuthHandler(cfg.Captcha)
assignmentHandler := handler.NewAssignmentHandler(notificationService)
adminHandler := handler.NewAdminHandler()
profileHandler := handler.NewProfileHandler(notificationService)
apiHandler := handler.NewAPIHandler()
apiRecurringHandler := handler.NewAPIRecurringHandler()
r.GET("/captcha/:file", gin.WrapH(captcha.Server(captcha.StdWidth, captcha.StdHeight)))
r.GET("/captcha-new", func(c *gin.Context) {
id := captcha.New()
c.String(http.StatusOK, id)
})
r.GET("/login/2fa", csrfMiddleware, authHandler.ShowLogin2FA)
r.POST("/login/2fa", csrfMiddleware, authHandler.Login2FA)
guest := r.Group("/")
guest.Use(middleware.GuestOnly())
guest.Use(csrfMiddleware)
@@ -252,6 +263,9 @@ func Setup(cfg *config.Config) *gin.Engine {
auth.POST("/profile", profileHandler.Update)
auth.POST("/profile/password", profileHandler.ChangePassword)
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
auth.GET("/profile/totp/setup", profileHandler.ShowTOTPSetup)
auth.POST("/profile/totp/setup", profileHandler.EnableTOTP)
auth.POST("/profile/totp/disable", profileHandler.DisableTOTP)
admin := auth.Group("/admin")
admin.Use(middleware.AdminRequired())

View File

@@ -104,3 +104,23 @@ func (s *AuthService) UpdateProfile(userID uint, name string) error {
user.Name = name
return s.userRepo.Update(user)
}
func (s *AuthService) EnableTOTP(userID uint, secret string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return ErrUserNotFound
}
user.TOTPSecret = secret
user.TOTPEnabled = true
return s.userRepo.Update(user)
}
func (s *AuthService) DisableTOTP(userID uint) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return ErrUserNotFound
}
user.TOTPSecret = ""
user.TOTPEnabled = false
return s.userRepo.Update(user)
}

View File

@@ -0,0 +1,75 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dchest/captcha"
)
type CaptchaService struct {
captchaType string
turnstileSecretKey string
}
func NewCaptchaService(captchaType, turnstileSecretKey string) *CaptchaService {
return &CaptchaService{
captchaType: captchaType,
turnstileSecretKey: turnstileSecretKey,
}
}
func (s *CaptchaService) NewImageCaptcha() string {
return captcha.New()
}
func (s *CaptchaService) VerifyImageCaptcha(id, answer string) bool {
return captcha.VerifyString(id, answer)
}
type turnstileResponse struct {
Success bool `json:"success"`
Errors []string `json:"error-codes"`
}
func (s *CaptchaService) VerifyTurnstile(token, remoteIP string) (bool, error) {
if token == "" {
return false, nil
}
form := url.Values{}
form.Set("secret", s.turnstileSecretKey)
form.Set("response", token)
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
resp, err := http.Post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
"application/x-www-form-urlencoded",
strings.NewReader(form.Encode()),
)
if err != nil {
return false, fmt.Errorf("turnstile request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("reading turnstile response: %w", err)
}
var result turnstileResponse
if err := json.Unmarshal(body, &result); err != nil {
return false, fmt.Errorf("parsing turnstile response: %w", err)
}
return result.Success, nil
}
func (s *CaptchaService) Type() string {
return s.captchaType
}

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
@@ -85,38 +84,6 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e
return nil
}
func (s *NotificationService) SendLineNotification(token, message string) error {
if token == "" {
return fmt.Errorf("LINE Notify token is empty")
}
apiURL := "https://notify-api.line.me/api/notify"
data := url.Values{}
data.Set("message", message)
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode)
}
return nil
}
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
settings, err := s.GetUserSettings(userID)
if err != nil {
@@ -139,12 +106,6 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
}
}
if settings.LineEnabled && settings.LineNotifyToken != "" {
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
errors = append(errors, fmt.Sprintf("LINE: %v", err))
}
}
if len(errors) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
}
@@ -162,7 +123,7 @@ func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, ass
return nil
}
if !settings.TelegramEnabled && !settings.LineEnabled {
if !settings.TelegramEnabled {
return nil
}
@@ -183,12 +144,6 @@ func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, ass
}
}
if settings.LineEnabled && settings.LineNotifyToken != "" {
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
errors = append(errors, fmt.Sprintf("LINE: %v", err))
}
}
if len(errors) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
}
@@ -252,12 +207,6 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
}
}
if settings.LineEnabled && settings.LineNotifyToken != "" {
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
errors = append(errors, fmt.Sprintf("LINE: %v", err))
}
}
if len(errors) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
}

View File

@@ -0,0 +1,71 @@
package service
import (
"bytes"
"encoding/base64"
"fmt"
"image/png"
"net/url"
"github.com/pquerna/otp"
totplib "github.com/pquerna/otp/totp"
)
type TOTPService struct{}
func NewTOTPService() *TOTPService {
return &TOTPService{}
}
type TOTPSetupData struct {
Secret string
QRCodeB64 string
OTPAuthURL string
}
func (s *TOTPService) GenerateSecret(email, issuer string) (*TOTPSetupData, error) {
key, err := totplib.Generate(totplib.GenerateOpts{
Issuer: issuer,
AccountName: email,
})
if err != nil {
return nil, err
}
return s.buildSetupData(key)
}
func (s *TOTPService) SetupDataFromSecret(secret, email, issuer string) (*TOTPSetupData, error) {
otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s",
url.PathEscape(issuer),
url.PathEscape(email),
url.QueryEscape(secret),
url.QueryEscape(issuer),
)
key, err := otp.NewKeyFromURL(otpURL)
if err != nil {
return nil, err
}
return s.buildSetupData(key)
}
func (s *TOTPService) buildSetupData(key *otp.Key) (*TOTPSetupData, error) {
img, err := key.Image(200, 200)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return &TOTPSetupData{
Secret: key.Secret(),
QRCodeB64: base64.StdEncoding.EncodeToString(buf.Bytes()),
OTPAuthURL: key.URL(),
}, nil
}
func (s *TOTPService) Validate(secret, code string) bool {
return totplib.Validate(code, secret)
}

View File

@@ -0,0 +1,173 @@
package validation
import (
"fmt"
"regexp"
"strings"
"unicode"
)
var MaxLengths = map[string]int{
"title": 200,
"description": 5000,
"subject": 100,
"priority": 20,
}
var xssPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)<\s*script`),
regexp.MustCompile(`(?i)</\s*script`),
regexp.MustCompile(`(?i)javascript\s*:`),
regexp.MustCompile(`(?i)on\w+\s*=`),
regexp.MustCompile(`(?i)<\s*iframe`),
regexp.MustCompile(`(?i)<\s*object`),
regexp.MustCompile(`(?i)<\s*embed`),
regexp.MustCompile(`(?i)<\s*svg[^>]*on\w+\s*=`),
regexp.MustCompile(`(?i)data\s*:\s*text/html`),
regexp.MustCompile(`(?i)<\s*img[^>]*on\w+\s*=`),
regexp.MustCompile(`(?i)expression\s*\(`),
regexp.MustCompile(`(?i)alert\s*\(`),
regexp.MustCompile(`(?i)confirm\s*\(`),
regexp.MustCompile(`(?i)prompt\s*\(`),
regexp.MustCompile(`(?i)document\s*\.\s*cookie`),
regexp.MustCompile(`(?i)document\s*\.\s*location`),
regexp.MustCompile(`(?i)window\s*\.\s*location`),
}
var sqlInjectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)'\s*or\s+`),
regexp.MustCompile(`(?i)'\s*and\s+`),
regexp.MustCompile(`(?i)"\s*or\s+`),
regexp.MustCompile(`(?i)"\s*and\s+`),
regexp.MustCompile(`(?i)union\s+(all\s+)?select`),
regexp.MustCompile(`(?i);\s*(drop|delete|update|insert|alter|truncate)\s+`),
regexp.MustCompile(`(?i)--\s*$`),
regexp.MustCompile(`(?i)/\*.*\*/`),
regexp.MustCompile(`(?i)'\s*;\s*`),
regexp.MustCompile(`(?i)exec\s*\(`),
regexp.MustCompile(`(?i)xp_\w+`),
regexp.MustCompile(`(?i)load_file\s*\(`),
regexp.MustCompile(`(?i)into\s+(out|dump)file`),
regexp.MustCompile(`(?i)benchmark\s*\(`),
regexp.MustCompile(`(?i)sleep\s*\(\s*\d`),
regexp.MustCompile(`(?i)waitfor\s+delay`),
regexp.MustCompile(`(?i)1\s*=\s*1`),
regexp.MustCompile(`(?i)'1'\s*=\s*'1`),
}
var pathTraversalPatterns = []*regexp.Regexp{
regexp.MustCompile(`\.\.[\\/]`),
regexp.MustCompile(`\.\.%2[fF]`),
regexp.MustCompile(`%2e%2e[\\/]`),
regexp.MustCompile(`\.\./`),
regexp.MustCompile(`\.\.\\`),
}
var commandInjectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`^\s*;`),
regexp.MustCompile(`;\s*\w+`),
regexp.MustCompile(`\|\s*\w+`),
regexp.MustCompile("`[^`]+`"),
regexp.MustCompile(`\$\([^)]+\)`),
regexp.MustCompile(`&&\s*\w+`),
regexp.MustCompile(`\|\|\s*\w+`),
}
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func ValidateAssignmentInput(title, description, subject, priority string) error {
if err := ValidateField("title", title, true); err != nil {
return err
}
if err := ValidateField("description", description, false); err != nil {
return err
}
if err := ValidateField("subject", subject, false); err != nil {
return err
}
if err := ValidateField("priority", priority, false); err != nil {
return err
}
return nil
}
func ValidateField(fieldName, value string, required bool) error {
if required && strings.TrimSpace(value) == "" {
return &ValidationError{Field: fieldName, Message: "必須項目です"}
}
if value == "" {
return nil
}
if maxLen, ok := MaxLengths[fieldName]; ok {
if len(value) > maxLen {
return &ValidationError{
Field: fieldName,
Message: fmt.Sprintf("最大%d文字までです", maxLen),
}
}
}
if fieldName != "description" {
for _, r := range value {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return &ValidationError{
Field: fieldName,
Message: "不正な制御文字が含まれています",
}
}
}
}
for _, pattern := range xssPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "潜在的に危険なHTMLタグまたはスクリプトが含まれています",
}
}
}
for _, pattern := range sqlInjectionPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "潜在的に危険なSQL構文が含まれています",
}
}
}
for _, pattern := range pathTraversalPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "不正なパス文字列が含まれています",
}
}
}
for _, pattern := range commandInjectionPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "潜在的に危険なコマンド構文が含まれています",
}
}
}
return nil
}
func SanitizeString(s string) string {
s = strings.ReplaceAll(s, "\x00", "")
s = strings.TrimSpace(s)
return s
}

View File

@@ -1,28 +1,56 @@
// Homework Manager JavaScript
const XSS = {
escapeHtml: function (str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
document.addEventListener('DOMContentLoaded', function() {
// Auto-dismiss alerts after 5 seconds (exclude alerts inside modals)
setTextSafe: function (element, text) {
if (element) {
element.textContent = text;
}
},
sanitizeUrl: function (url) {
if (!url) return '';
const cleaned = String(url).replace(/[\x00-\x1F\x7F]/g, '').trim();
try {
const parsed = new URL(cleaned, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (e) {
if (cleaned.startsWith('/') && !cleaned.startsWith('//')) {
return cleaned;
}
}
return '';
}
};
window.XSS = XSS;
document.addEventListener('DOMContentLoaded', function () {
const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)');
alerts.forEach(function(alert) {
setTimeout(function() {
alerts.forEach(function (alert) {
setTimeout(function () {
alert.classList.add('fade');
setTimeout(function() {
setTimeout(function () {
alert.remove();
}, 150);
}, 5000);
});
// Confirm dialogs for dangerous actions
const confirmForms = document.querySelectorAll('form[data-confirm]');
confirmForms.forEach(function(form) {
form.addEventListener('submit', function(e) {
confirmForms.forEach(function (form) {
form.addEventListener('submit', function (e) {
if (!confirm(form.dataset.confirm)) {
e.preventDefault();
}
});
});
// Set default datetime to now + 1 day for new assignments
const dueDateInput = document.getElementById('due_date');
if (dueDateInput && !dueDateInput.value) {
const tomorrow = new Date();

View File

@@ -1,5 +1,11 @@
{{template "base" .}}
{{define "head"}}
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{{end}}
{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
@@ -25,6 +31,28 @@
<label for="password" class="form-label">パスワード</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
{{if .captchaEnabled}}
{{if eq .captchaType "turnstile"}}
<div class="mb-3 d-flex justify-content-center">
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
</div>
{{else if eq .captchaType "image"}}
<div class="mb-3">
<label class="form-label">画像認証</label>
<div class="d-flex align-items-center gap-2 mb-2">
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
<input type="text" class="form-control" name="captcha_answer"
placeholder="上の数字を入力" autocomplete="off" required>
</div>
{{end}}
{{end}}
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
</div>
@@ -40,4 +68,19 @@
</div>
</div>
</div>
{{end}}
{{end}}
{{define "scripts"}}
{{if and .captchaEnabled (eq .captchaType "image")}}
<script>
function reloadCaptcha() {
fetch('/captcha-new')
.then(r => r.text())
.then(id => {
document.getElementById('captchaID').value = id;
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
});
}
</script>
{{end}}
{{end}}

View File

@@ -0,0 +1,46 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-shield-lock display-4 text-primary"></i>
<h2 class="mt-2">2段階認証</h2>
<p class="text-muted small">認証アプリに表示されている6桁のコードを入力してください</p>
</div>
{{if .error}}
<div class="alert alert-danger">{{.error}}</div>
{{end}}
<form method="POST" action="/login/2fa">
{{.csrfField}}
<div class="mb-4">
<label for="totp_code" class="form-label">認証コード</label>
<input type="text" class="form-control form-control-lg text-center"
id="totp_code" name="totp_code"
placeholder="000000"
maxlength="6" pattern="[0-9]{6}"
inputmode="numeric" autocomplete="one-time-code"
autofocus required>
<div class="form-text text-center">Google Authenticator などのアプリで確認</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-shield-check me-1"></i>確認
</button>
</div>
</form>
<hr class="my-4">
<div class="text-center">
<a href="/login" class="text-muted small">別のアカウントでログイン</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -1,5 +1,11 @@
{{template "base" .}}
{{define "head"}}
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{{end}}
{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
@@ -28,14 +34,36 @@
<div class="mb-3">
<label for="password" class="form-label">パスワード</label>
<input type="password" class="form-control" id="password" name="password" required
minlength="6">
<div class="form-text">6文字以上</div>
minlength="8">
<div class="form-text">8文字以上</div>
</div>
<div class="mb-3">
<label for="password_confirm" class="form-label">パスワード(確認)</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
required minlength="6">
required minlength="8">
</div>
{{if .captchaEnabled}}
{{if eq .captchaType "turnstile"}}
<div class="mb-3 d-flex justify-content-center">
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
</div>
{{else if eq .captchaType "image"}}
<div class="mb-3">
<label class="form-label">画像認証</label>
<div class="d-flex align-items-center gap-2 mb-2">
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
<input type="text" class="form-control" name="captcha_answer"
placeholder="上の数字を入力" autocomplete="off" required>
</div>
{{end}}
{{end}}
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">登録</button>
</div>
@@ -51,4 +79,19 @@
</div>
</div>
</div>
{{end}}
{{end}}
{{define "scripts"}}
{{if and .captchaEnabled (eq .captchaType "image")}}
<script>
function reloadCaptcha() {
fetch('/captcha-new')
.then(r => r.text())
.then(id => {
document.getElementById('captchaID').value = id;
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
});
}
</script>
{{end}}
{{end}}

View File

@@ -67,6 +67,43 @@
</div>
</div>
<!-- 2段階認証設定 -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証2FA</h5>
</div>
<div class="card-body">
{{if .totpError}}<div class="alert alert-danger">{{.totpError}}</div>{{end}}
{{if .totpSuccess}}<div class="alert alert-success">{{.totpSuccess}}</div>{{end}}
{{if .user.TOTPEnabled}}
<div class="d-flex align-items-center mb-3">
<span class="badge bg-success me-2"><i class="bi bi-check-lg me-1"></i>有効</span>
<span class="text-muted">2段階認証が有効になっています</span>
</div>
<form method="POST" action="/profile/totp/disable">
{{.csrfField}}
<div class="mb-3">
<label for="totp_disable_password" class="form-label">現在のパスワードを入力して無効化</label>
<input type="password" class="form-control" id="totp_disable_password" name="password"
placeholder="パスワード" required style="max-width:320px">
</div>
<button type="submit" class="btn btn-danger">
<i class="bi bi-shield-x me-1"></i>2段階認証を無効化
</button>
</form>
{{else}}
<div class="d-flex align-items-center mb-3">
<span class="badge bg-secondary me-2"><i class="bi bi-x-lg me-1"></i>無効</span>
<span class="text-muted">2段階認証が設定されていません</span>
</div>
<p class="text-muted small">2段階認証を有効にするとセキュリティが向上します。Google Authenticator などのアプリが必要です。</p>
<a href="/profile/totp/setup" class="btn btn-primary">
<i class="bi bi-shield-plus me-1"></i>2段階認証を設定する
</a>
{{end}}
</div>
</div>
<!-- 通知設定 -->
<div class="card mt-4">
<div class="card-header">
@@ -95,22 +132,6 @@
</div>
</div>
</div>
<div class="col-md-6">
<h6 class="mb-3"><i class="bi bi-chat-dots me-1"></i>LINE Notify</h6>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="line_enabled" name="line_enabled"
{{if .notifySettings.LineEnabled}}checked{{end}}>
<label class="form-check-label" for="line_enabled">LINE通知を有効化</label>
</div>
<div class="mb-3">
<label for="line_token" class="form-label">アクセストークン</label>
<input type="password" class="form-control" id="line_token" name="line_token"
value="{{.notifySettings.LineNotifyToken}}" placeholder="トークンを入力">
<div class="form-text">
<a href="https://notify-bot.line.me/my/" target="_blank">LINE Notify</a>でトークンを発行
</div>
</div>
</div>
</div>
<hr class="my-3">
<div class="form-check form-switch mb-3">

View File

@@ -0,0 +1,83 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-7 col-lg-6">
<div class="card shadow">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証の設定</h5>
</div>
<div class="card-body p-4">
{{if .error}}
<div class="alert alert-danger">{{.error}}</div>
{{end}}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
Google Authenticator、Authy などの認証アプリを使用してください。
</div>
<h6 class="fw-bold mb-3">手順</h6>
<ol class="mb-4">
<li class="mb-2">認証アプリを開き、新しいアカウントを追加してください。</li>
<li class="mb-2">下のQRコードをスキャンするか、シークレットキーを手動で入力してください。</li>
<li class="mb-2">アプリに表示された6桁のコードを下の欄に入力して確認してください。</li>
</ol>
<div class="text-center mb-4">
<img src="data:image/png;base64,{{.qrCode}}" alt="QRコード" class="border rounded"
style="max-width:200px">
</div>
<div class="mb-4">
<label class="form-label fw-bold">シークレットキー(手動入力の場合)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="secretKey" value="{{.secret}}"
readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copySecret()" title="コピー">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text">認証アプリで「手動入力」を選択し、このキーを入力してください。</div>
</div>
<form method="POST" action="/profile/totp/setup">
{{.csrfField}}
<div class="mb-3">
<label for="totp_password" class="form-label fw-bold">現在のパスワード</label>
<input type="password" class="form-control" id="totp_password" name="password"
placeholder="パスワードを入力" required>
</div>
<div class="mb-3">
<label for="totp_code" class="form-label fw-bold">認証コードで確認</label>
<input type="text" class="form-control form-control-lg text-center" id="totp_code"
name="totp_code" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
autocomplete="off" autofocus required>
<div class="form-text">認証アプリに表示された6桁のコードを入力してください。</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-shield-check me-1"></i>有効化
</button>
<a href="/profile" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
function copySecret() {
const el = document.getElementById('secretKey');
el.select();
navigator.clipboard.writeText(el.value).then(() => {
const btn = el.nextElementSibling;
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
});
}
</script>
{{end}}