Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1029f29033 | |||
| 8ef7ded91f | |||
| aaaecbfab4 | |||
| 2a4fac79b1 | |||
| 1113477111 | |||
| 080bd1f8d7 | |||
| 45bf048c47 | |||
| fee5d7c846 | |||
| 5b10b90bf5 | |||
| b982c8acee |
27
Caddyfile
Normal file
27
Caddyfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Caddyfile - Reverse Proxy Configuration
|
||||||
|
#
|
||||||
|
# 使用方法:
|
||||||
|
# 本番環境で公開する場合は、以下の `:80` を実際のドメイン(例: example.com)に変更してください。
|
||||||
|
# 変更して docker compose up -d を再実行すると、自動的にHTTPS証明書が取得されます。
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
reverse_proxy app:8080
|
||||||
|
|
||||||
|
# ログ設定
|
||||||
|
log {
|
||||||
|
output file /data/access.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
|
||||||
|
# セキュリティヘッダー
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# gzip 圧縮
|
||||||
|
encode gzip
|
||||||
|
}
|
||||||
33
Dockerfile
33
Dockerfile
@@ -1,12 +1,8 @@
|
|||||||
# Builder stage
|
# Builder stage
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-trixie AS builder
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install git if needed for fetching dependencies (sometimes needed even with go modules)
|
|
||||||
# RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Copy go mod and sum files
|
# Copy go mod and sum files
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
@@ -21,12 +17,18 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go
|
RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM alpine:latest
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -r -u 1000 -s /bin/false appuser
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
@@ -35,8 +37,21 @@ COPY --from=builder /app/server .
|
|||||||
# Copy web assets (templates, static files)
|
# Copy web assets (templates, static files)
|
||||||
COPY --from=builder /app/web ./web
|
COPY --from=builder /app/web ./web
|
||||||
|
|
||||||
# Expose port (adjust if your app uses a different port)
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Volume for persistent data
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
ENTRYPOINT ["./server"]
|
ENTRYPOINT ["./server"]
|
||||||
|
|||||||
157
README.md
157
README.md
@@ -1,101 +1,112 @@
|
|||||||
# Homework Manager
|
<div align="center">
|
||||||
|
|
||||||
シンプルな課題管理アプリケーションです。学生の課題管理を効率化するために設計されています。
|
# Super Homework Manager
|
||||||
|
|
||||||
|
シンプルで高機能な課題管理アプリケーション
|
||||||
|
|
||||||
|
[](https://go.dev/)
|
||||||
|
[](LICENSE.md)
|
||||||
|
[](#docker-での実行)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
学生の課題管理を効率化するために設計されたWebアプリケーションです。
|
||||||
|
繰り返し課題の自動生成やダッシュボードによる期限管理など、日々の課題管理をサポートします。
|
||||||
|
|
||||||
|
## スクリーンショット
|
||||||
|
|
||||||
|
| ダッシュボード | 課題一覧 | API |
|
||||||
|
|:---:|:---:|:---:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
## 特徴
|
## 特徴
|
||||||
|
|
||||||
- **課題管理**: 課題の登録、編集、削除、完了状況の管理
|
| 機能 | 説明 |
|
||||||
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
|
|---|---|
|
||||||
- **API対応**: 外部連携用のRESTful API (APIキー認証)
|
| **課題管理** | 課題の登録・編集・削除・完了状況の管理 |
|
||||||
- **セキュリティ**:
|
| **繰り返し課題** | 日次・週次・月次の繰り返し課題を自動生成 |
|
||||||
- CSRF対策
|
| **ダッシュボード** | 期限切れ・本日期限・今週期限の課題をひと目で確認 |
|
||||||
- レート制限 (Rate Limiting)
|
| **REST API** | 外部連携用のAPIキー認証付きRESTful API |
|
||||||
- セキュアなセッション管理
|
| **セキュリティ** | CSRF対策 / レート制限 / セキュアなセッション管理 / 2FA対応 |
|
||||||
- **ポータビリティ**: Pure Go SQLiteドライバー使用により、CGO不要でどこでも動作
|
| **ポータビリティ** | Pure Go SQLiteドライバー使用でCGO不要 |
|
||||||
|
|
||||||

|
## クイックスタート
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
|
### 前提条件
|
||||||
|
|
||||||
## 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): 機能詳細、データモデル、設定項目
|
> **注意**: 本番環境(外部公開するサーバー上)で使用する場合は、`Caddyfile` の `:80` を実際のドメインに変更し、`config.ini` 内の `[session] secret` 等の安全な文字列への変更を必ず行ってください。
|
||||||
- [APIドキュメント](docs/API.md): APIのエンドポイント、リクエスト/レスポンス形式
|
|
||||||
|
|
||||||
## 前提条件
|
### ローカルビルド(開発者向け)
|
||||||
|
|
||||||
- Go 1.24 以上
|
開発目的で直接実行する場合の手順です。
|
||||||
|
|
||||||
## インストール方法
|
```bash
|
||||||
|
# 1. リポジトリのクローン
|
||||||
|
git clone <repository-url>
|
||||||
|
cd Homework-Manager
|
||||||
|
|
||||||
1. **リポジトリのクローン**
|
# 2. 依存関係のダウンロード
|
||||||
```bash
|
go mod download
|
||||||
git clone <repository-url>
|
|
||||||
cd Homework-Manager
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **依存関係のダウンロード**
|
# 3. ビルド
|
||||||
```bash
|
go build -o homework-manager cmd/server/main.go
|
||||||
go mod download
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **アプリケーションのビルド**
|
# 4. 設定ファイルの準備
|
||||||
```bash
|
cp config.ini.example config.ini
|
||||||
go build -o homework-manager cmd/server/main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **設定ファイルの準備**
|
# 5. 実行
|
||||||
サンプル設定ファイルをコピーして、`config.ini` を作成します。
|
./homework-manager
|
||||||
|
```
|
||||||
```bash
|
|
||||||
cp config.ini.example config.ini
|
|
||||||
```
|
|
||||||
※ Windows (PowerShell): `Copy-Item config.ini.example config.ini`
|
|
||||||
|
|
||||||
**重要**: 本番環境で使用する場合は、必ず `[session] secret` と `[security] csrf_secret` を変更してください。
|
> **Windows (PowerShell)** の場合:
|
||||||
|
> `Copy-Item config.ini.example config.ini` → `.\homework-manager.exe`
|
||||||
|
|
||||||
5. **アプリケーションの実行**
|
ブラウザで **http://localhost:8080** にアクセスしてください。
|
||||||
```bash
|
|
||||||
./homework-manager
|
|
||||||
```
|
|
||||||
※ Windows (PowerShell): `.\homework-manager.exe`
|
|
||||||
|
|
||||||
ブラウザで `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` で最新コードを取得
|
```bash
|
||||||
2. `go build -o homework-manager cmd/server/main.go` で再ビルド
|
git pull
|
||||||
3. アプリケーションを再起動
|
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)です。
|
[AGPLv3 (GNU Affero General Public License v3)](LICENSE.md)
|
||||||
詳しくはLICENSEファイルをご覧ください。
|
|
||||||
|
|||||||
46
config.ini.docker.example
Normal file
46
config.ini.docker.example
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
; Homework Manager 設定ファイル (Docker用)
|
||||||
|
; docker-compose.yml と一緒に使用してください
|
||||||
|
; ローカル実行する場合securityセクションのhttpsをfalseに変更するのを忘れないでください!!
|
||||||
|
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
debug = false
|
||||||
|
|
||||||
|
[database]
|
||||||
|
driver = mysql
|
||||||
|
host = db
|
||||||
|
port = 3306
|
||||||
|
user = homework
|
||||||
|
password = homework_password
|
||||||
|
name = homework_manager
|
||||||
|
|
||||||
|
[session]
|
||||||
|
; 本番環境では必ず変更してください
|
||||||
|
secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
allow_registration = true
|
||||||
|
|
||||||
|
[security]
|
||||||
|
https = false
|
||||||
|
; こちらも本番環境では必ず変更してください
|
||||||
|
csrf_secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
|
||||||
|
rate_limit_enabled = true
|
||||||
|
rate_limit_requests = 100
|
||||||
|
rate_limit_window = 60
|
||||||
|
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
|
||||||
@@ -58,3 +58,15 @@ rate_limit_window = 60
|
|||||||
; Telegram Bot Token (@BotFatherで取得)
|
; Telegram Bot Token (@BotFatherで取得)
|
||||||
; ユーザーはプロフィール画面でChat IDを設定します
|
; ユーザーはプロフィール画面でChat IDを設定します
|
||||||
telegram_bot_token =
|
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
|
||||||
|
|||||||
@@ -1,11 +1,62 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
container_name: homework-manager
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./homework.db:/app/homework.db
|
- ./config.ini:/app/config.ini:ro
|
||||||
- ./config.ini:/app/config.ini
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Tokyo
|
- TZ=Asia/Tokyo
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: homework-db
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-homework_manager}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-homework}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-homework_password}
|
||||||
|
TZ: Asia/Tokyo
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: homework-caddy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Tokyo
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
|||||||
380
docs/API.md
380
docs/API.md
@@ -23,14 +23,15 @@ Super Homework Manager REST APIは、課題管理機能をプログラムから
|
|||||||
### 認証ヘッダー
|
### 認証ヘッダー
|
||||||
|
|
||||||
```
|
```
|
||||||
X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
Authorization: Bearer hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
```
|
```
|
||||||
|
|
||||||
### 認証エラー
|
### 認証エラー
|
||||||
|
|
||||||
| ステータスコード | レスポンス |
|
| ステータスコード | レスポンス |
|
||||||
|------------------|------------|
|
|------------------|------------|
|
||||||
| 401 Unauthorized | `{"error": "API key required"}` |
|
| 401 Unauthorized | `{"error": "Authorization header required"}` |
|
||||||
|
| 401 Unauthorized | `{"error": "Invalid authorization format. Use: Bearer <api_key>"}` |
|
||||||
| 401 Unauthorized | `{"error": "Invalid API key"}` |
|
| 401 Unauthorized | `{"error": "Invalid API key"}` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -39,13 +40,22 @@ X-API-Key: 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` | 課題詳細取得 |
|
| GET | `/api/v1/assignments/:id` | 課題詳細取得 |
|
||||||
| POST | `/api/v1/assignments` | 課題作成 |
|
| POST | `/api/v1/assignments` | 課題作成 |
|
||||||
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
||||||
| DELETE | `/api/v1/assignments/:id` | 課題削除 |
|
| DELETE | `/api/v1/assignments/:id` | 課題削除 |
|
||||||
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
|
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
|
||||||
| GET | `/api/v1/statistics` | 統計情報取得 |
|
| GET | `/api/v1/statistics` | 統計情報取得 |
|
||||||
|
| GET | `/api/v1/recurring` | 繰り返し設定一覧取得 |
|
||||||
|
| GET | `/api/v1/recurring/:id` | 繰り返し設定詳細取得 |
|
||||||
|
| PUT | `/api/v1/recurring/:id` | 繰り返し設定更新 |
|
||||||
|
| DELETE | `/api/v1/recurring/:id` | 繰り返し設定削除 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,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`) |
|
||||||
|
|
||||||
### レスポンス
|
### レスポンス
|
||||||
|
|
||||||
@@ -74,13 +86,18 @@ GET /api/v1/assignments
|
|||||||
"title": "数学レポート",
|
"title": "数学レポート",
|
||||||
"description": "第5章の練習問題",
|
"description": "第5章の練習問題",
|
||||||
"subject": "数学",
|
"subject": "数学",
|
||||||
|
"priority": "medium",
|
||||||
"due_date": "2025-01-15T23:59:00+09:00",
|
"due_date": "2025-01-15T23:59:00+09:00",
|
||||||
"is_completed": false,
|
"is_completed": false,
|
||||||
"created_at": "2025-01-10T10:00:00+09:00",
|
"created_at": "2025-01-10T10:00:00+09:00",
|
||||||
"updated_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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -88,13 +105,45 @@ GET /api/v1/assignments
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 全件取得
|
# 全件取得
|
||||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments
|
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments
|
||||||
|
|
||||||
# 未完了のみ取得
|
# 未完了のみ(ページネーション付き)
|
||||||
curl -H "X-API-Key: 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 "X-API-Key: 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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -122,6 +171,7 @@ GET /api/v1/assignments/:id
|
|||||||
"title": "数学レポート",
|
"title": "数学レポート",
|
||||||
"description": "第5章の練習問題",
|
"description": "第5章の練習問題",
|
||||||
"subject": "数学",
|
"subject": "数学",
|
||||||
|
"priority": "medium",
|
||||||
"due_date": "2025-01-15T23:59:00+09:00",
|
"due_date": "2025-01-15T23:59:00+09:00",
|
||||||
"is_completed": false,
|
"is_completed": false,
|
||||||
"created_at": "2025-01-10T10:00:00+09:00",
|
"created_at": "2025-01-10T10:00:00+09:00",
|
||||||
@@ -140,7 +190,7 @@ GET /api/v1/assignments/:id
|
|||||||
### 例
|
### 例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments/1
|
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/1
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -158,7 +208,30 @@ POST /api/v1/assignments
|
|||||||
| `title` | string | ✅ | 課題タイトル |
|
| `title` | string | ✅ | 課題タイトル |
|
||||||
| `description` | string | | 説明 |
|
| `description` | string | | 説明 |
|
||||||
| `subject` | string | | 教科・科目 |
|
| `subject` | string | | 教科・科目 |
|
||||||
|
| `priority` | string | | 重要度: `low`, `medium`, `high`(デフォルト: `medium`) |
|
||||||
| `due_date` | string | ✅ | 提出期限(RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD`) |
|
| `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 | | 繰り返し設定(下記参照) |
|
||||||
|
|
||||||
|
### Recurrence オブジェクト
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `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 | 終了回数(`count` 指定時) |
|
||||||
|
| `date` | string | 終了日(`date` 指定時) |
|
||||||
|
|
||||||
### リクエスト例
|
### リクエスト例
|
||||||
|
|
||||||
@@ -167,6 +240,7 @@ POST /api/v1/assignments
|
|||||||
"title": "英語エッセイ",
|
"title": "英語エッセイ",
|
||||||
"description": "テーマ自由、1000語以上",
|
"description": "テーマ自由、1000語以上",
|
||||||
"subject": "英語",
|
"subject": "英語",
|
||||||
|
"priority": "high",
|
||||||
"due_date": "2025-01-20T17:00"
|
"due_date": "2025-01-20T17:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -182,6 +256,7 @@ POST /api/v1/assignments
|
|||||||
"title": "英語エッセイ",
|
"title": "英語エッセイ",
|
||||||
"description": "テーマ自由、1000語以上",
|
"description": "テーマ自由、1000語以上",
|
||||||
"subject": "英語",
|
"subject": "英語",
|
||||||
|
"priority": "high",
|
||||||
"due_date": "2025-01-20T17:00:00+09:00",
|
"due_date": "2025-01-20T17:00:00+09:00",
|
||||||
"is_completed": false,
|
"is_completed": false,
|
||||||
"created_at": "2025-01-10T11:00:00+09:00",
|
"created_at": "2025-01-10T11:00:00+09:00",
|
||||||
@@ -189,11 +264,20 @@ POST /api/v1/assignments
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
繰り返し設定を含む場合は `recurring_assignment` を返します:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Recurring assignment created",
|
||||||
|
"recurring_assignment": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**400 Bad Request**
|
**400 Bad Request**
|
||||||
|
|
||||||
```json
|
```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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -201,9 +285,9 @@ POST /api/v1/assignments
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "X-API-Key: hm_xxx" \
|
-H "Authorization: Bearer hm_xxx" \
|
||||||
-H "Content-Type: application/json" \
|
-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
|
http://localhost:8080/api/v1/assignments
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -230,7 +314,11 @@ PUT /api/v1/assignments/:id
|
|||||||
| `title` | string | 課題タイトル |
|
| `title` | string | 課題タイトル |
|
||||||
| `description` | string | 説明 |
|
| `description` | string | 説明 |
|
||||||
| `subject` | string | 教科・科目 |
|
| `subject` | string | 教科・科目 |
|
||||||
|
| `priority` | string | 重要度: `low`, `medium`, `high` |
|
||||||
| `due_date` | string | 提出期限 |
|
| `due_date` | string | 提出期限 |
|
||||||
|
| `reminder_enabled` | boolean | リマインダー有効/無効 |
|
||||||
|
| `reminder_at` | string | リマインダー時刻 |
|
||||||
|
| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 |
|
||||||
|
|
||||||
### リクエスト例
|
### リクエスト例
|
||||||
|
|
||||||
@@ -243,21 +331,7 @@ PUT /api/v1/assignments/:id
|
|||||||
|
|
||||||
### レスポンス
|
### レスポンス
|
||||||
|
|
||||||
**200 OK**
|
**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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**404 Not Found**
|
**404 Not Found**
|
||||||
|
|
||||||
@@ -271,7 +345,7 @@ PUT /api/v1/assignments/:id
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT \
|
curl -X PUT \
|
||||||
-H "X-API-Key: hm_xxx" \
|
-H "Authorization: Bearer hm_xxx" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"title":"更新されたタイトル"}' \
|
-d '{"title":"更新されたタイトル"}' \
|
||||||
http://localhost:8080/api/v1/assignments/2
|
http://localhost:8080/api/v1/assignments/2
|
||||||
@@ -291,30 +365,44 @@ DELETE /api/v1/assignments/:id
|
|||||||
|------------|------|------|
|
|------------|------|------|
|
||||||
| `id` | integer | 課題ID |
|
| `id` | integer | 課題ID |
|
||||||
|
|
||||||
|
### クエリパラメータ
|
||||||
|
|
||||||
|
| パラメータ | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `delete_recurring` | boolean | `true` の場合、関連する繰り返し設定も削除する |
|
||||||
|
|
||||||
### レスポンス
|
### レスポンス
|
||||||
|
|
||||||
**200 OK**
|
**200 OK**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "message": "Assignment deleted" }
|
||||||
"message": "Assignment deleted"
|
```
|
||||||
}
|
|
||||||
|
繰り返し設定も削除した場合:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "Assignment and recurring settings deleted" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**404 Not Found**
|
**404 Not Found**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "error": "Assignment not found" }
|
||||||
"error": "Assignment not found"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 例
|
### 例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 課題のみ削除
|
||||||
curl -X DELETE \
|
curl -X DELETE \
|
||||||
-H "X-API-Key: hm_xxx" \
|
-H "Authorization: Bearer hm_xxx" \
|
||||||
http://localhost:8080/api/v1/assignments/2
|
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"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -344,6 +432,7 @@ PATCH /api/v1/assignments/:id/toggle
|
|||||||
"title": "数学レポート",
|
"title": "数学レポート",
|
||||||
"description": "第5章の練習問題",
|
"description": "第5章の練習問題",
|
||||||
"subject": "数学",
|
"subject": "数学",
|
||||||
|
"priority": "medium",
|
||||||
"due_date": "2025-01-15T23:59:00+09:00",
|
"due_date": "2025-01-15T23:59:00+09:00",
|
||||||
"is_completed": true,
|
"is_completed": true,
|
||||||
"completed_at": "2025-01-12T14:30:00+09:00",
|
"completed_at": "2025-01-12T14:30:00+09:00",
|
||||||
@@ -355,16 +444,14 @@ PATCH /api/v1/assignments/:id/toggle
|
|||||||
**404 Not Found**
|
**404 Not Found**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "error": "Assignment not found" }
|
||||||
"error": "Assignment not found"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 例
|
### 例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH \
|
curl -X PATCH \
|
||||||
-H "X-API-Key: hm_xxx" \
|
-H "Authorization: Bearer hm_xxx" \
|
||||||
http://localhost:8080/api/v1/assignments/1/toggle
|
http://localhost:8080/api/v1/assignments/1/toggle
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -372,7 +459,7 @@ curl -X PATCH \
|
|||||||
|
|
||||||
## 統計情報取得
|
## 統計情報取得
|
||||||
|
|
||||||
ユーザーの課題統計を取得します。科目、日付範囲でフィルタリング可能です。
|
ユーザーの課題統計を取得します。
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/statistics
|
GET /api/v1/statistics
|
||||||
@@ -383,8 +470,9 @@ GET /api/v1/statistics
|
|||||||
| パラメータ | 型 | 説明 |
|
| パラメータ | 型 | 説明 |
|
||||||
|------------|------|------|
|
|------------|------|------|
|
||||||
| `subject` | string | 科目で絞り込み(省略時: 全科目) |
|
| `subject` | string | 科目で絞り込み(省略時: 全科目) |
|
||||||
| `from` | string | 課題登録日の開始日(YYYY-MM-DD形式) |
|
| `from` | string | 課題登録日の開始日(`YYYY-MM-DD`) |
|
||||||
| `to` | string | 課題登録日の終了日(YYYY-MM-DD形式) |
|
| `to` | string | 課題登録日の終了日(`YYYY-MM-DD`) |
|
||||||
|
| `include_archived` | boolean | アーカイブ済み課題を含む(デフォルト: `false`) |
|
||||||
|
|
||||||
### レスポンス
|
### レスポンス
|
||||||
|
|
||||||
@@ -410,50 +498,191 @@ GET /api/v1/statistics
|
|||||||
"pending": 2,
|
"pending": 2,
|
||||||
"overdue": 1,
|
"overdue": 1,
|
||||||
"on_time_completion_rate": 91.7
|
"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
|
```bash
|
||||||
# 全体統計
|
# 全体統計
|
||||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/statistics
|
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/statistics
|
||||||
|
|
||||||
# 科目で絞り込み
|
# 科目で絞り込み
|
||||||
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学"
|
curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学"
|
||||||
|
|
||||||
# 日付範囲で絞り込み
|
# 日付範囲で絞り込み
|
||||||
curl -H "X-API-Key: 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?from=2025-01-01&to=2025-03-31"
|
||||||
|
```
|
||||||
|
|
||||||
# 科目と日付範囲の組み合わせ
|
---
|
||||||
curl -H "X-API-Key: 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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -475,6 +704,7 @@ curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数
|
|||||||
| 400 Bad Request | リクエストの形式が不正 |
|
| 400 Bad Request | リクエストの形式が不正 |
|
||||||
| 401 Unauthorized | 認証エラー |
|
| 401 Unauthorized | 認証エラー |
|
||||||
| 404 Not Found | リソースが見つからない |
|
| 404 Not Found | リソースが見つからない |
|
||||||
|
| 429 Too Many Requests | レート制限超過 |
|
||||||
| 500 Internal Server Error | サーバー内部エラー |
|
| 500 Internal Server Error | サーバー内部エラー |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -485,7 +715,7 @@ APIは以下の日付形式を受け付けます(優先度順):
|
|||||||
|
|
||||||
1. **RFC3339**: `2025-01-15T23:59:00+09:00`
|
1. **RFC3339**: `2025-01-15T23:59:00+09:00`
|
||||||
2. **日時形式**: `2025-01-15T23:59`
|
2. **日時形式**: `2025-01-15T23:59`
|
||||||
3. **日付のみ**: `2025-01-15`(時刻は23:59に設定)
|
3. **日付のみ**: `2025-01-15`(時刻は `23:59` に設定)
|
||||||
|
|
||||||
レスポンスの日付はすべてRFC3339形式で返されます。
|
レスポンスの日付はすべてRFC3339形式で返されます。
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Super Homework Managerは、学生の課題管理を支援するWebアプリケ
|
|||||||
|
|
||||||
| 項目 | 技術 |
|
| 項目 | 技術 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 言語 | Go |
|
| 言語 | Go 1.24+ |
|
||||||
| Webフレームワーク | Gin |
|
| Webフレームワーク | Gin |
|
||||||
| データベース | SQLite (GORM with Pure Go driver - glebarez/sqlite) |
|
| データベース | SQLite / MySQL / PostgreSQL (GORM、Pure Go SQLiteドライバー使用) |
|
||||||
| セッション管理 | gin-contrib/sessions (Cookie store) |
|
| セッション管理 | gin-contrib/sessions (Cookie store) |
|
||||||
| テンプレートエンジン | Go html/template |
|
| テンプレートエンジン | Go html/template |
|
||||||
| コンテナ | Docker対応 |
|
| コンテナ | Docker対応 |
|
||||||
@@ -27,7 +27,8 @@ homework-manager/
|
|||||||
│ ├── middleware/ # ミドルウェア
|
│ ├── middleware/ # ミドルウェア
|
||||||
│ ├── models/ # データモデル
|
│ ├── models/ # データモデル
|
||||||
│ ├── repository/ # データアクセス層
|
│ ├── repository/ # データアクセス層
|
||||||
│ └── service/ # ビジネスロジック
|
│ ├── service/ # ビジネスロジック
|
||||||
|
│ └── validation/ # 入力バリデーション
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── static/ # 静的ファイル (CSS, JS)
|
│ ├── static/ # 静的ファイル (CSS, JS)
|
||||||
│ └── templates/ # HTMLテンプレート
|
│ └── templates/ # HTMLテンプレート
|
||||||
@@ -50,6 +51,8 @@ homework-manager/
|
|||||||
| PasswordHash | string | パスワードハッシュ | Not Null |
|
| PasswordHash | string | パスワードハッシュ | Not Null |
|
||||||
| Name | string | 表示名 | Not Null |
|
| Name | string | 表示名 | Not Null |
|
||||||
| Role | string | 権限 (`user` or `admin`) | Default: `user` |
|
| Role | string | 権限 (`user` or `admin`) | Default: `user` |
|
||||||
|
| TOTPSecret | string | TOTP秘密鍵 | - |
|
||||||
|
| TOTPEnabled | bool | 2FA有効フラグ | Default: false |
|
||||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
@@ -79,7 +82,33 @@ homework-manager/
|
|||||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
|
|
||||||
### 2.3 UserNotificationSettings(通知設定)
|
|
||||||
|
### 2.3 RecurringAssignment(繰り返し課題)
|
||||||
|
|
||||||
|
繰り返し課題の設定を管理するモデル。
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 | 制約 |
|
||||||
|
|------------|------|------|------|
|
||||||
|
| ID | uint | 設定ID | Primary Key |
|
||||||
|
| UserID | uint | 所有ユーザーID | Not Null, Index |
|
||||||
|
| Title | string | 課題タイトル | Not Null |
|
||||||
|
| Description | string | 説明 | - |
|
||||||
|
| Subject | string | 教科・科目 | - |
|
||||||
|
| Priority | string | 重要度 | Default: `medium` |
|
||||||
|
| RecurrenceType | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`) | Not Null |
|
||||||
|
| RecurrenceInterval | int | 繰り返し間隔 | Default: 1 |
|
||||||
|
| RecurrenceWeekday | *int | 曜日 (0-6, 日-土) | Nullable |
|
||||||
|
| RecurrenceDay | *int | 日 (1-31) | Nullable |
|
||||||
|
| DueTime | string | 締切時刻 (HH:MM) | Not Null |
|
||||||
|
| EndType | string | 終了条件 (`never`, `count`, `date`) | Default: `never` |
|
||||||
|
| EndCount | *int | 終了回数 | Nullable |
|
||||||
|
| EndDate | *time.Time | 終了日 | Nullable |
|
||||||
|
| IsActive | bool | 有効フラグ | Default: true |
|
||||||
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
|
|
||||||
|
### 2.4 UserNotificationSettings(通知設定)
|
||||||
|
|
||||||
ユーザーの通知設定を管理するモデル。
|
ユーザーの通知設定を管理するモデル。
|
||||||
|
|
||||||
@@ -89,13 +118,12 @@ homework-manager/
|
|||||||
| UserID | uint | ユーザーID | Unique, Not Null |
|
| UserID | uint | ユーザーID | Unique, Not Null |
|
||||||
| TelegramEnabled | bool | Telegram通知 | Default: false |
|
| TelegramEnabled | bool | Telegram通知 | Default: false |
|
||||||
| TelegramChatID | string | Telegram Chat ID | - |
|
| TelegramChatID | string | Telegram Chat ID | - |
|
||||||
| LineEnabled | bool | LINE通知 | Default: false |
|
| NotifyOnCreate | bool | 課題追加時に通知 | Default: true |
|
||||||
| LineNotifyToken | string | LINE Notifyトークン | - |
|
|
||||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
|
|
||||||
### 2.4 APIKey(APIキー)
|
### 2.5 APIKey(APIキー)
|
||||||
|
|
||||||
REST API認証用のAPIキーを管理するモデル。
|
REST API認証用のAPIキーを管理するモデル。
|
||||||
|
|
||||||
@@ -120,10 +148,11 @@ REST API認証用のAPIキーを管理するモデル。
|
|||||||
- **パスワード要件**: 8文字以上
|
- **パスワード要件**: 8文字以上
|
||||||
- **パスワードハッシュ**: bcryptを使用
|
- **パスワードハッシュ**: bcryptを使用
|
||||||
- **CSRF対策**: 全フォームでのトークン検証
|
- **CSRF対策**: 全フォームでのトークン検証
|
||||||
|
- **2段階認証 (TOTP)**: プロフィール画面からGoogle Authenticator等で設定可能。有効化後はログイン時にワンタイムパスワードの入力が必要
|
||||||
|
|
||||||
### 3.2 API認証
|
### 3.2 API認証
|
||||||
|
|
||||||
- **APIキー認証**: `X-API-Key` ヘッダーで認証
|
- **APIキー認証**: `Authorization: Bearer <API_KEY>` ヘッダーで認証
|
||||||
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
|
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
|
||||||
- **ハッシュ保存**: SHA-256でハッシュ化して保存
|
- **ハッシュ保存**: SHA-256でハッシュ化して保存
|
||||||
|
|
||||||
@@ -133,6 +162,7 @@ REST API認証用のAPIキーを管理するモデル。
|
|||||||
|--------|------|
|
|--------|------|
|
||||||
| `user` | 自分の課題のCRUD操作、プロフィール管理 |
|
| `user` | 自分の課題のCRUD操作、プロフィール管理 |
|
||||||
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
|
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
|
||||||
|
|
||||||
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
|
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -144,8 +174,9 @@ REST API認証用のAPIキーを管理するモデル。
|
|||||||
| 機能 | 説明 |
|
| 機能 | 説明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 新規登録 | メールアドレス、パスワード、名前で登録 |
|
| 新規登録 | メールアドレス、パスワード、名前で登録 |
|
||||||
| ログイン | メールアドレスとパスワードでログイン |
|
| ログイン | メールアドレスとパスワードでログイン。2FA有効時は続けてTOTPコードを入力 |
|
||||||
| ログアウト | セッションをクリアしてログアウト |
|
| ログアウト | セッションをクリアしてログアウト |
|
||||||
|
| CAPTCHA | ログイン・登録フォームへのbot対策(画像認証またはCloudflare Turnstile) |
|
||||||
|
|
||||||
### 4.2 課題管理機能
|
### 4.2 課題管理機能
|
||||||
|
|
||||||
@@ -155,22 +186,35 @@ REST API認証用のAPIキーを管理するモデル。
|
|||||||
| 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 |
|
| 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 |
|
||||||
| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 |
|
| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 |
|
||||||
| 課題編集 | 既存の課題情報を編集 |
|
| 課題編集 | 既存の課題情報を編集 |
|
||||||
| 課題削除 | 課題を論理削除 |
|
| 課題削除 | 課題を論理削除(繰り返し課題に関連する場合、繰り返し設定ごと削除するか選択可能) |
|
||||||
| 完了トグル | 課題の完了/未完了状態を切り替え |
|
| 完了トグル | 課題の完了/未完了状態を切り替え |
|
||||||
| 統計 | 科目別の完了率、期限内完了率等を表示 |
|
| 統計 | 科目別の完了率、期限内完了率等を表示 |
|
||||||
|
|
||||||
### 4.3 通知機能
|
### 4.3 繰り返し課題機能
|
||||||
|
|
||||||
#### 4.3.1 1回リマインダー
|
周期的に発生する課題を自動生成する機能。
|
||||||
|
|
||||||
|
| 機能 | 説明 |
|
||||||
|
|------|------|
|
||||||
|
| 繰り返し作成 | 課題登録時に繰り返し条件(毎日/毎週/毎月)を設定して作成 |
|
||||||
|
| 自動生成 | 未完了の課題がなくなったタイミングで、設定に基づき次回の課題を自動生成 |
|
||||||
|
| 繰り返し一覧 | 登録されている繰り返し設定を一覧表示 (`/recurring`) |
|
||||||
|
| 繰り返し編集 | 繰り返し設定の内容(タイトル、条件、時刻など)を編集 |
|
||||||
|
| 停止・再開 | 繰り返し設定を一時停止、または停止中の設定を再開 |
|
||||||
|
| 繰り返し削除 | 繰り返し設定を完全に削除 |
|
||||||
|
|
||||||
|
### 4.4 通知機能
|
||||||
|
|
||||||
|
#### 4.4.1 1回リマインダー
|
||||||
|
|
||||||
指定した日時に1回だけ通知を送信する機能。
|
指定した日時に1回だけ通知を送信する機能。
|
||||||
|
|
||||||
| 項目 | 説明 |
|
| 項目 | 説明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 設定 | 課題登録・編集画面で通知日時を指定 |
|
| 設定 | 課題登録・編集画面で通知日時を指定 |
|
||||||
| 送信 | 指定日時にTelegram/LINEで通知 |
|
| 送信 | 指定日時にTelegramで通知 |
|
||||||
|
|
||||||
#### 4.3.2 督促通知
|
#### 4.4.2 督促通知
|
||||||
|
|
||||||
課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。
|
課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。
|
||||||
|
|
||||||
@@ -182,23 +226,24 @@ REST API認証用のAPIキーを管理するモデル。
|
|||||||
| 重要度「小」 | **60分**ごとに通知 |
|
| 重要度「小」 | **60分**ごとに通知 |
|
||||||
| 停止条件 | 課題の完了ボタンを押すまで継続 |
|
| 停止条件 | 課題の完了ボタンを押すまで継続 |
|
||||||
|
|
||||||
#### 4.3.3 通知チャンネル
|
#### 4.4.3 通知チャンネル
|
||||||
|
|
||||||
| チャンネル | 設定方法 |
|
| チャンネル | 設定方法 |
|
||||||
|------------|----------|
|
|------------|----------|
|
||||||
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
|
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
|
||||||
| LINE Notify | プロフィールでアクセストークン入力 |
|
|
||||||
|
|
||||||
### 4.4 プロフィール機能
|
### 4.5 プロフィール機能
|
||||||
|
|
||||||
| 機能 | 説明 |
|
| 機能 | 説明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| プロフィール表示 | ユーザー情報を表示 |
|
| プロフィール表示 | ユーザー情報を表示 |
|
||||||
| プロフィール更新 | 表示名を変更 |
|
| プロフィール更新 | 表示名を変更 |
|
||||||
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
|
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
|
||||||
| 通知設定 | Telegram/LINE通知の有効化とトークン設定 |
|
| 通知設定 | Telegram通知の有効化とChat ID設定 |
|
||||||
|
| 2FA設定 | TOTPアプリ(Google Authenticator等)でQRコードをスキャンし2FAを有効化 |
|
||||||
|
| 2FA無効化 | 有効中の2FAを無効化 |
|
||||||
|
|
||||||
### 4.4 管理者機能
|
### 4.6 管理者機能
|
||||||
|
|
||||||
| 機能 | 説明 |
|
| 機能 | 説明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -240,7 +285,14 @@ rate_limit_requests = 100
|
|||||||
rate_limit_window = 60
|
rate_limit_window = 60
|
||||||
|
|
||||||
[notification]
|
[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 設定項目
|
### 5.2 設定項目
|
||||||
@@ -249,16 +301,26 @@ rate_limit_window = 60
|
|||||||
|------------|------|------|--------------|
|
|------------|------|------|--------------|
|
||||||
| `server` | `port` | サーバーポート | `8080` |
|
| `server` | `port` | サーバーポート | `8080` |
|
||||||
| `server` | `debug` | デバッグモード | `true` |
|
| `server` | `debug` | デバッグモード | `true` |
|
||||||
| `database` | `driver` | DBドライバー (sqlite, mysql, postgres) | `sqlite` |
|
| `database` | `driver` | DBドライバー (`sqlite`, `mysql`, `postgres`) | `sqlite` |
|
||||||
| `database` | `path` | SQLiteファイルパス | `homework.db` |
|
| `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` |
|
| `auth` | `allow_registration` | 新規登録許可 | `true` |
|
||||||
| `security` | `https` | HTTPS設定(Secure Cookie) | `false` |
|
| `security` | `https` | HTTPS設定 (Secure Cookie) | `false` |
|
||||||
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | (必須) |
|
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | **(必須)** |
|
||||||
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
||||||
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
||||||
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
||||||
|
| `security` | `trusted_proxies` | 信頼するプロキシ | - |
|
||||||
| `notification` | `telegram_bot_token` | Telegram Bot Token | - |
|
| `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 環境変数
|
### 5.3 環境変数
|
||||||
|
|
||||||
@@ -269,13 +331,22 @@ rate_limit_window = 60
|
|||||||
| `PORT` | サーバーポート |
|
| `PORT` | サーバーポート |
|
||||||
| `DATABASE_DRIVER` | データベースドライバー |
|
| `DATABASE_DRIVER` | データベースドライバー |
|
||||||
| `DATABASE_PATH` | SQLiteデータベースファイルパス |
|
| `DATABASE_PATH` | SQLiteデータベースファイルパス |
|
||||||
|
| `DATABASE_HOST` | DBホスト |
|
||||||
|
| `DATABASE_PORT` | DBポート |
|
||||||
|
| `DATABASE_USER` | DBユーザー |
|
||||||
|
| `DATABASE_PASSWORD` | DBパスワード |
|
||||||
|
| `DATABASE_NAME` | DB名 |
|
||||||
| `SESSION_SECRET` | セッション暗号化キー |
|
| `SESSION_SECRET` | セッション暗号化キー |
|
||||||
| `CSRF_SECRET` | CSRFトークン秘密鍵 |
|
| `CSRF_SECRET` | CSRFトークン秘密鍵 |
|
||||||
| `GIN_MODE` | `release` でリリースモード(debug=false) |
|
| `GIN_MODE` | `release` でリリースモード |
|
||||||
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
|
| `ALLOW_REGISTRATION` | 新規登録許可 (`true`/`false`) |
|
||||||
| `HTTPS` | HTTPSモード (true/false) |
|
| `HTTPS` | HTTPSモード (`true`/`false`) |
|
||||||
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
|
| `TRUSTED_PROXIES` | 信頼するプロキシ |
|
||||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
|
| `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 設定の優先順位
|
### 5.4 設定の優先順位
|
||||||
|
|
||||||
@@ -290,13 +361,16 @@ rate_limit_window = 60
|
|||||||
### 6.1 実装済みセキュリティ機能
|
### 6.1 実装済みセキュリティ機能
|
||||||
|
|
||||||
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
|
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
|
||||||
|
- **2段階認証 (TOTP)**: RFC 6238準拠のTOTPによる2FA
|
||||||
|
- **CAPTCHA**: ログイン・登録時のbot対策(画像認証またはCloudflare Turnstile)
|
||||||
- **セッションセキュリティ**: HttpOnly Cookie
|
- **セッションセキュリティ**: HttpOnly Cookie
|
||||||
- **入力バリデーション**: 各ハンドラで基本的な入力検証
|
- **入力バリデーション**: 各ハンドラで基本的な入力検証
|
||||||
- **CSFR対策**: Double Submit Cookieパターンまたは同期トークンによるCSRF保護
|
- **CSRF対策**: Double Submit Cookieパターンによる全フォーム保護
|
||||||
- **レート制限**: IPベースのリクエスト制限によるDoS対策
|
- **レート制限**: IPベースのリクエスト制限によるDoS対策
|
||||||
- **論理削除**: データの完全削除を防ぐソフトデリート
|
- **論理削除**: データの完全削除を防ぐソフトデリート
|
||||||
- **権限チェック**: ミドルウェアによるロールベースアクセス制御
|
- **権限チェック**: ミドルウェアによるロールベースアクセス制御
|
||||||
- **Secure Cookie**: HTTPS設定時のSecure属性付与
|
- **Secure Cookie**: HTTPS設定時のSecure属性付与
|
||||||
|
- **セキュリティヘッダー**: X-Content-Type-Options, X-Frame-Options等の設定
|
||||||
|
|
||||||
### 6.2 推奨される本番環境設定
|
### 6.2 推奨される本番環境設定
|
||||||
|
|
||||||
@@ -304,6 +378,7 @@ rate_limit_window = 60
|
|||||||
- HTTPSを有効化し、`HTTPS=true` を設定
|
- HTTPSを有効化し、`HTTPS=true` を設定
|
||||||
- `GIN_MODE=release` を設定
|
- `GIN_MODE=release` を設定
|
||||||
- 必要に応じて `TRUSTED_PROXIES` を設定
|
- 必要に応じて `TRUSTED_PROXIES` を設定
|
||||||
|
- `CAPTCHA_ENABLED=true` を設定してbot対策を強化
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -17,10 +17,12 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.14.2 // indirect
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // 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/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ type NotificationConfig struct {
|
|||||||
TelegramBotToken string
|
TelegramBotToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CaptchaConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Type string // "turnstile" or "image"
|
||||||
|
TurnstileSiteKey string
|
||||||
|
TurnstileSecretKey string
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
@@ -34,6 +41,7 @@ type Config struct {
|
|||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
Database DatabaseConfig
|
Database DatabaseConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
|
Captcha CaptchaConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(configPath string) *Config {
|
func Load(configPath string) *Config {
|
||||||
@@ -56,6 +64,10 @@ func Load(configPath string) *Config {
|
|||||||
Password: "",
|
Password: "",
|
||||||
Name: "homework_manager",
|
Name: "homework_manager",
|
||||||
},
|
},
|
||||||
|
Captcha: CaptchaConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Type: "image",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
@@ -134,6 +146,21 @@ func Load(configPath string) *Config {
|
|||||||
if section.HasKey("telegram_bot_token") {
|
if section.HasKey("telegram_bot_token") {
|
||||||
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
|
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 {
|
} else {
|
||||||
log.Println("config.ini not found, using environment variables or defaults")
|
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 != "" {
|
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
|
||||||
cfg.Notification.TelegramBotToken = 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 == "" {
|
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.")
|
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||||
|
|||||||
@@ -7,17 +7,20 @@ import (
|
|||||||
|
|
||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
"homework-manager/internal/validation"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIHandler struct {
|
type APIHandler struct {
|
||||||
assignmentService *service.AssignmentService
|
assignmentService *service.AssignmentService
|
||||||
|
recurringService *service.RecurringAssignmentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPIHandler() *APIHandler {
|
func NewAPIHandler() *APIHandler {
|
||||||
return &APIHandler{
|
return &APIHandler{
|
||||||
assignmentService: service.NewAssignmentService(),
|
assignmentService: service.NewAssignmentService(),
|
||||||
|
recurringService: service.NewRecurringAssignmentService(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,17 +29,12 @@ func (h *APIHandler) getUserID(c *gin.Context) uint {
|
|||||||
return userID.(uint)
|
return userID.(uint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAssignments returns all assignments for the authenticated user with pagination
|
|
||||||
// GET /api/v1/assignments?filter=pending&page=1&page_size=20
|
|
||||||
func (h *APIHandler) ListAssignments(c *gin.Context) {
|
func (h *APIHandler) ListAssignments(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
filter := c.Query("filter") // pending, completed, overdue
|
filter := c.Query("filter") // pending, completed, overdue
|
||||||
|
|
||||||
// Parse pagination parameters
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
// Validate pagination parameters
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -44,10 +42,9 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
|
|||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
if pageSize > 100 {
|
if pageSize > 100 {
|
||||||
pageSize = 100 // Maximum page size to prevent abuse
|
pageSize = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use paginated methods for filtered queries
|
|
||||||
switch filter {
|
switch filter {
|
||||||
case "completed":
|
case "completed":
|
||||||
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
|
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
|
||||||
@@ -56,12 +53,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"assignments": result.Assignments,
|
"assignments": result.Assignments,
|
||||||
"count": len(result.Assignments),
|
"count": len(result.Assignments),
|
||||||
"total_count": result.TotalCount,
|
"total_count": result.TotalCount,
|
||||||
"total_pages": result.TotalPages,
|
"total_pages": result.TotalPages,
|
||||||
"current_page": result.CurrentPage,
|
"current_page": result.CurrentPage,
|
||||||
"page_size": result.PageSize,
|
"page_size": result.PageSize,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "overdue":
|
case "overdue":
|
||||||
@@ -71,12 +68,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"assignments": result.Assignments,
|
"assignments": result.Assignments,
|
||||||
"count": len(result.Assignments),
|
"count": len(result.Assignments),
|
||||||
"total_count": result.TotalCount,
|
"total_count": result.TotalCount,
|
||||||
"total_pages": result.TotalPages,
|
"total_pages": result.TotalPages,
|
||||||
"current_page": result.CurrentPage,
|
"current_page": result.CurrentPage,
|
||||||
"page_size": result.PageSize,
|
"page_size": result.PageSize,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -86,23 +83,21 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"assignments": result.Assignments,
|
"assignments": result.Assignments,
|
||||||
"count": len(result.Assignments),
|
"count": len(result.Assignments),
|
||||||
"total_count": result.TotalCount,
|
"total_count": result.TotalCount,
|
||||||
"total_pages": result.TotalPages,
|
"total_pages": result.TotalPages,
|
||||||
"current_page": result.CurrentPage,
|
"current_page": result.CurrentPage,
|
||||||
"page_size": result.PageSize,
|
"page_size": result.PageSize,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// For "all" filter, use simple pagination without a dedicated method
|
|
||||||
assignments, err := h.assignmentService.GetAllByUser(userID)
|
assignments, err := h.assignmentService.GetAllByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual pagination for all assignments
|
|
||||||
totalCount := len(assignments)
|
totalCount := len(assignments)
|
||||||
totalPages := (totalCount + pageSize - 1) / pageSize
|
totalPages := (totalCount + pageSize - 1) / pageSize
|
||||||
start := (page - 1) * pageSize
|
start := (page - 1) * pageSize
|
||||||
@@ -115,18 +110,16 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"assignments": assignments[start:end],
|
"assignments": assignments[start:end],
|
||||||
"count": end - start,
|
"count": end - start,
|
||||||
"total_count": totalCount,
|
"total_count": totalCount,
|
||||||
"total_pages": totalPages,
|
"total_pages": totalPages,
|
||||||
"current_page": page,
|
"current_page": page,
|
||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPendingAssignments returns pending assignments with pagination
|
|
||||||
// GET /api/v1/assignments/pending?page=1&page_size=20
|
|
||||||
func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
|
func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
page, pageSize := h.parsePagination(c)
|
page, pageSize := h.parsePagination(c)
|
||||||
@@ -140,8 +133,6 @@ func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
|
|||||||
h.sendPaginatedResponse(c, result)
|
h.sendPaginatedResponse(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCompletedAssignments returns completed assignments with pagination
|
|
||||||
// GET /api/v1/assignments/completed?page=1&page_size=20
|
|
||||||
func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
|
func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
page, pageSize := h.parsePagination(c)
|
page, pageSize := h.parsePagination(c)
|
||||||
@@ -155,8 +146,6 @@ func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
|
|||||||
h.sendPaginatedResponse(c, result)
|
h.sendPaginatedResponse(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListOverdueAssignments returns overdue assignments with pagination
|
|
||||||
// GET /api/v1/assignments/overdue?page=1&page_size=20
|
|
||||||
func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
|
func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
page, pageSize := h.parsePagination(c)
|
page, pageSize := h.parsePagination(c)
|
||||||
@@ -170,8 +159,6 @@ func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
|
|||||||
h.sendPaginatedResponse(c, result)
|
h.sendPaginatedResponse(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDueTodayAssignments returns assignments due today
|
|
||||||
// GET /api/v1/assignments/due-today
|
|
||||||
func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
|
func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
@@ -187,8 +174,6 @@ func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDueThisWeekAssignments returns assignments due within this week
|
|
||||||
// GET /api/v1/assignments/due-this-week
|
|
||||||
func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
|
func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
@@ -204,7 +189,6 @@ func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePagination extracts and validates pagination parameters
|
|
||||||
func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
|
func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
|
||||||
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
@@ -221,7 +205,6 @@ func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
|
|||||||
return page, pageSize
|
return page, pageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendPaginatedResponse sends a standard paginated JSON response
|
|
||||||
func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) {
|
func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"assignments": result.Assignments,
|
"assignments": result.Assignments,
|
||||||
@@ -233,8 +216,6 @@ func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.Pagin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAssignment returns a single assignment by ID
|
|
||||||
// GET /api/v1/assignments/:id
|
|
||||||
func (h *APIHandler) GetAssignment(c *gin.Context) {
|
func (h *APIHandler) GetAssignment(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
@@ -252,40 +233,126 @@ func (h *APIHandler) GetAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, assignment)
|
c.JSON(http.StatusOK, assignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAssignmentInput represents the JSON input for creating an assignment
|
|
||||||
type CreateAssignmentInput struct {
|
type CreateAssignmentInput struct {
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Priority string `json:"priority"` // low, medium, high (default: medium)
|
Priority string `json:"priority"`
|
||||||
DueDate string `json:"due_date" binding:"required"` // RFC3339 or 2006-01-02T15:04
|
DueDate string `json:"due_date" binding:"required"`
|
||||||
|
|
||||||
|
ReminderEnabled bool `json:"reminder_enabled"`
|
||||||
|
ReminderAt string `json:"reminder_at"`
|
||||||
|
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
|
||||||
|
Recurrence struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
Weekday interface{} `json:"weekday"`
|
||||||
|
Day interface{} `json:"day"`
|
||||||
|
Until struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
} `json:"until"`
|
||||||
|
} `json:"recurrence"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAssignment creates a new assignment
|
|
||||||
// POST /api/v1/assignments
|
|
||||||
func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
var input CreateAssignmentInput
|
var input CreateAssignmentInput
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: title and due_date are required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dueDate, err := time.Parse(time.RFC3339, input.DueDate)
|
if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
|
||||||
if err != nil {
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
|
return
|
||||||
if err != nil {
|
|
||||||
dueDate, err = time.ParseInLocation("2006-01-02", input.DueDate, time.Local)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, false, nil, true)
|
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"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminderAt *time.Time
|
||||||
|
if input.ReminderEnabled && input.ReminderAt != "" {
|
||||||
|
reminderTime, err := parseDateString(input.ReminderAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reminderAt = &reminderTime
|
||||||
|
}
|
||||||
|
|
||||||
|
urgentReminder := true
|
||||||
|
if input.UrgentReminderEnabled != nil {
|
||||||
|
urgentReminder = *input.UrgentReminderEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Recurrence.Type != "" && input.Recurrence.Type != "none" {
|
||||||
|
serviceInput := service.CreateRecurringAssignmentInput{
|
||||||
|
Title: input.Title,
|
||||||
|
Description: input.Description,
|
||||||
|
Subject: input.Subject,
|
||||||
|
Priority: input.Priority,
|
||||||
|
FirstDueDate: dueDate,
|
||||||
|
DueTime: dueDate.Format("15:04"),
|
||||||
|
RecurrenceType: input.Recurrence.Type,
|
||||||
|
RecurrenceInterval: input.Recurrence.Interval,
|
||||||
|
ReminderEnabled: input.ReminderEnabled,
|
||||||
|
ReminderOffset: nil,
|
||||||
|
UrgentReminderEnabled: urgentReminder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if serviceInput.RecurrenceInterval < 1 {
|
||||||
|
serviceInput.RecurrenceInterval = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Recurrence.Weekday != nil {
|
||||||
|
if wd, ok := input.Recurrence.Weekday.(float64); ok {
|
||||||
|
wdInt := int(wd)
|
||||||
|
serviceInput.RecurrenceWeekday = &wdInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Recurrence.Day != nil {
|
||||||
|
if d, ok := input.Recurrence.Day.(float64); ok {
|
||||||
|
dInt := int(d)
|
||||||
|
serviceInput.RecurrenceDay = &dInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceInput.EndType = input.Recurrence.Until.Type
|
||||||
|
if serviceInput.EndType == "" {
|
||||||
|
serviceInput.EndType = "never"
|
||||||
|
}
|
||||||
|
|
||||||
|
if serviceInput.EndType == "count" {
|
||||||
|
count := input.Recurrence.Until.Count
|
||||||
|
serviceInput.EndCount = &count
|
||||||
|
} else if serviceInput.EndType == "date" && input.Recurrence.Until.Date != "" {
|
||||||
|
endDate, err := parseDateString(input.Recurrence.Until.Date)
|
||||||
|
if err == nil {
|
||||||
|
serviceInput.EndDate = &endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recurring, err := h.recurringService.Create(userID, serviceInput)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create recurring assignment: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Recurring assignment created",
|
||||||
|
"recurring_assignment": recurring,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, input.ReminderEnabled, reminderAt, urgentReminder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
||||||
return
|
return
|
||||||
@@ -294,17 +361,17 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, assignment)
|
c.JSON(http.StatusCreated, assignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAssignmentInput represents the JSON input for updating an assignment
|
|
||||||
type UpdateAssignmentInput struct {
|
type UpdateAssignmentInput struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Priority string `json:"priority"`
|
Priority string `json:"priority"`
|
||||||
DueDate string `json:"due_date"`
|
DueDate string `json:"due_date"`
|
||||||
|
ReminderEnabled *bool `json:"reminder_enabled"`
|
||||||
|
ReminderAt string `json:"reminder_at"`
|
||||||
|
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAssignment updates an existing assignment
|
|
||||||
// PUT /api/v1/assignments/:id
|
|
||||||
func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
@@ -313,7 +380,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing assignment
|
|
||||||
existing, err := h.assignmentService.GetByID(userID, uint(id))
|
existing, err := h.assignmentService.GetByID(userID, uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
@@ -326,14 +392,26 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use existing values if not provided
|
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
|
title := input.Title
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = existing.Title
|
title = existing.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
description := input.Description
|
description := input.Description
|
||||||
|
if description == "" {
|
||||||
|
description = existing.Description
|
||||||
|
}
|
||||||
|
|
||||||
subject := input.Subject
|
subject := input.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = existing.Subject
|
||||||
|
}
|
||||||
|
|
||||||
priority := input.Priority
|
priority := input.Priority
|
||||||
if priority == "" {
|
if priority == "" {
|
||||||
priority = existing.Priority
|
priority = existing.Priority
|
||||||
@@ -341,18 +419,36 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
|||||||
|
|
||||||
dueDate := existing.DueDate
|
dueDate := existing.DueDate
|
||||||
if input.DueDate != "" {
|
if input.DueDate != "" {
|
||||||
dueDate, err = time.Parse(time.RFC3339, input.DueDate)
|
parsedDate, err := parseDateString(input.DueDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
|
||||||
if err != nil {
|
return
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
dueDate = parsedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve existing reminder settings for API updates
|
reminderEnabled := existing.ReminderEnabled
|
||||||
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, existing.ReminderEnabled, existing.ReminderAt, existing.UrgentReminderEnabled)
|
if input.ReminderEnabled != nil {
|
||||||
|
reminderEnabled = *input.ReminderEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
reminderAt := existing.ReminderAt
|
||||||
|
if input.ReminderAt != "" {
|
||||||
|
parsedReminderAt, err := parseDateString(input.ReminderAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reminderAt = &parsedReminderAt
|
||||||
|
} else if input.ReminderEnabled != nil && !*input.ReminderEnabled {
|
||||||
|
}
|
||||||
|
|
||||||
|
urgentReminderEnabled := existing.UrgentReminderEnabled
|
||||||
|
if input.UrgentReminderEnabled != nil {
|
||||||
|
urgentReminderEnabled = *input.UrgentReminderEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
||||||
return
|
return
|
||||||
@@ -361,8 +457,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, assignment)
|
c.JSON(http.StatusOK, assignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAssignment deletes an assignment
|
|
||||||
// DELETE /api/v1/assignments/:id
|
|
||||||
func (h *APIHandler) DeleteAssignment(c *gin.Context) {
|
func (h *APIHandler) DeleteAssignment(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
@@ -371,6 +465,26 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteRecurring := c.Query("delete_recurring") == "true"
|
||||||
|
|
||||||
|
if deleteRecurring {
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignment.RecurringAssignmentID != nil {
|
||||||
|
if err := h.recurringService.Delete(userID, *assignment.RecurringAssignmentID, false); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete recurring assignment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Assignment and recurring settings deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.assignmentService.Delete(userID, uint(id)); err != nil {
|
if err := h.assignmentService.Delete(userID, uint(id)); err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
return
|
return
|
||||||
@@ -379,8 +493,6 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleAssignment toggles the completion status of an assignment
|
|
||||||
// PATCH /api/v1/assignments/:id/toggle
|
|
||||||
func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
@@ -398,18 +510,14 @@ func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, assignment)
|
c.JSON(http.StatusOK, assignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatistics returns statistics for the authenticated user
|
|
||||||
// GET /api/v1/statistics?subject=数学&from=2025-01-01&to=2025-12-31&include_archived=true
|
|
||||||
func (h *APIHandler) GetStatistics(c *gin.Context) {
|
func (h *APIHandler) GetStatistics(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
// Parse filter parameters
|
|
||||||
filter := service.StatisticsFilter{
|
filter := service.StatisticsFilter{
|
||||||
Subject: c.Query("subject"),
|
Subject: c.Query("subject"),
|
||||||
IncludeArchived: c.Query("include_archived") == "true",
|
IncludeArchived: c.Query("include_archived") == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse from date
|
|
||||||
if fromStr := c.Query("from"); fromStr != "" {
|
if fromStr := c.Query("from"); fromStr != "" {
|
||||||
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -419,7 +527,6 @@ func (h *APIHandler) GetStatistics(c *gin.Context) {
|
|||||||
filter.From = &fromDate
|
filter.From = &fromDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse to date
|
|
||||||
if toStr := c.Query("to"); toStr != "" {
|
if toStr := c.Query("to"); toStr != "" {
|
||||||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -438,3 +545,18 @@ func (h *APIHandler) GetStatistics(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseDateString(dateStr string) (time.Time, error) {
|
||||||
|
t, err := time.Parse(time.RFC3339, dateStr)
|
||||||
|
if err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
t, err = time.ParseInLocation("2006-01-02T15:04", dateStr, time.Local)
|
||||||
|
if err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
t, err = time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||||
|
if err == nil {
|
||||||
|
return t.Add(23*time.Hour + 59*time.Minute), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|||||||
167
internal/handler/api_recurring_handler.go
Normal file
167
internal/handler/api_recurring_handler.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIRecurringHandler struct {
|
||||||
|
recurringService *service.RecurringAssignmentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIRecurringHandler() *APIRecurringHandler {
|
||||||
|
return &APIRecurringHandler{
|
||||||
|
recurringService: service.NewRecurringAssignmentService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIRecurringHandler) getUserID(c *gin.Context) uint {
|
||||||
|
userID, _ := c.Get(middleware.UserIDKey)
|
||||||
|
return userID.(uint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIRecurringHandler) ListRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
|
recurringList, err := h.recurringService.GetAllByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch recurring assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"recurring_assignments": recurringList,
|
||||||
|
"count": len(recurringList),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIRecurringHandler) GetRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recurring, err := h.recurringService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, recurring)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRecurringAPIInput struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Subject *string `json:"subject"`
|
||||||
|
Priority *string `json:"priority"`
|
||||||
|
RecurrenceType *string `json:"recurrence_type"`
|
||||||
|
RecurrenceInterval *int `json:"recurrence_interval"`
|
||||||
|
RecurrenceWeekday *int `json:"recurrence_weekday"`
|
||||||
|
RecurrenceDay *int `json:"recurrence_day"`
|
||||||
|
DueTime *string `json:"due_time"`
|
||||||
|
EndType *string `json:"end_type"`
|
||||||
|
EndCount *int `json:"end_count"`
|
||||||
|
EndDate *string `json:"end_date"` // YYYY-MM-DD
|
||||||
|
IsActive *bool `json:"is_active"` // To stop/resume
|
||||||
|
ReminderEnabled *bool `json:"reminder_enabled"`
|
||||||
|
ReminderOffset *int `json:"reminder_offset"`
|
||||||
|
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
|
||||||
|
EditBehavior string `json:"edit_behavior"` // this_only, this_and_future, all (default: this_only)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIRecurringHandler) UpdateRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input UpdateRecurringAPIInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := h.recurringService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.IsActive != nil {
|
||||||
|
if err := h.recurringService.SetActive(userID, uint(id), *input.IsActive); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update active status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing.IsActive = *input.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceInput := service.UpdateRecurringInput{
|
||||||
|
Title: input.Title,
|
||||||
|
Description: input.Description,
|
||||||
|
Subject: input.Subject,
|
||||||
|
Priority: input.Priority,
|
||||||
|
RecurrenceType: input.RecurrenceType,
|
||||||
|
RecurrenceInterval: input.RecurrenceInterval,
|
||||||
|
RecurrenceWeekday: input.RecurrenceWeekday,
|
||||||
|
RecurrenceDay: input.RecurrenceDay,
|
||||||
|
DueTime: input.DueTime,
|
||||||
|
EndType: input.EndType,
|
||||||
|
EndCount: input.EndCount,
|
||||||
|
EditBehavior: input.EditBehavior,
|
||||||
|
ReminderEnabled: input.ReminderEnabled,
|
||||||
|
ReminderOffset: input.ReminderOffset,
|
||||||
|
UrgentReminderEnabled: input.UrgentReminderEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.EndDate != nil && *input.EndDate != "" {
|
||||||
|
endDate, err := parseDateString(*input.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceInput.EndDate = &endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if serviceInput.EditBehavior == "" {
|
||||||
|
serviceInput.EditBehavior = models.EditBehaviorThisOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.recurringService.Update(userID, uint(id), serviceInput)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update recurring assignment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.IsActive = existing.IsActive
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIRecurringHandler) DeleteRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.recurringService.Delete(userID, uint(id), false)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found or failed to delete"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Recurring assignment deleted"})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
"homework-manager/internal/models"
|
"homework-manager/internal/models"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
"homework-manager/internal/validation"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -16,12 +17,14 @@ import (
|
|||||||
type AssignmentHandler struct {
|
type AssignmentHandler struct {
|
||||||
assignmentService *service.AssignmentService
|
assignmentService *service.AssignmentService
|
||||||
notificationService *service.NotificationService
|
notificationService *service.NotificationService
|
||||||
|
recurringService *service.RecurringAssignmentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler {
|
func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler {
|
||||||
return &AssignmentHandler{
|
return &AssignmentHandler{
|
||||||
assignmentService: service.NewAssignmentService(),
|
assignmentService: service.NewAssignmentService(),
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
|
recurringService: service.NewRecurringAssignmentService(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,11 +107,14 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
|||||||
func (h *AssignmentHandler) New(c *gin.Context) {
|
func (h *AssignmentHandler) New(c *gin.Context) {
|
||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||||
"title": "課題登録",
|
"title": "課題登録",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
|
"currentWeekday": int(now.Weekday()),
|
||||||
|
"currentDay": now.Day(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +127,22 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
|||||||
priority := c.PostForm("priority")
|
priority := c.PostForm("priority")
|
||||||
dueDateStr := c.PostForm("due_date")
|
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"
|
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||||
reminderAtStr := c.PostForm("reminder_at")
|
reminderAtStr := c.PostForm("reminder_at")
|
||||||
var reminderAt *time.Time
|
var reminderAt *time.Time
|
||||||
@@ -196,7 +218,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
|||||||
dueTime := dueDate.Format("15:04")
|
dueTime := dueDate.Format("15:04")
|
||||||
|
|
||||||
recurringService := service.NewRecurringAssignmentService()
|
recurringService := service.NewRecurringAssignmentService()
|
||||||
input := service.CreateRecurringInput{
|
input := service.CreateRecurringAssignmentInput{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
@@ -266,12 +288,18 @@ func (h *AssignmentHandler) Edit(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var recurring *models.RecurringAssignment
|
||||||
|
if assignment.RecurringAssignmentID != nil {
|
||||||
|
recurring, _ = h.recurringService.GetByID(userID, *assignment.RecurringAssignmentID)
|
||||||
|
}
|
||||||
|
|
||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{
|
RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{
|
||||||
"title": "課題編集",
|
"title": "課題編集",
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
|
"recurring": recurring,
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
})
|
})
|
||||||
@@ -287,6 +315,11 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
|||||||
priority := c.PostForm("priority")
|
priority := c.PostForm("priority")
|
||||||
dueDateStr := c.PostForm("due_date")
|
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"
|
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||||
reminderAtStr := c.PostForm("reminder_at")
|
reminderAtStr := c.PostForm("reminder_at")
|
||||||
var reminderAt *time.Time
|
var reminderAt *time.Time
|
||||||
@@ -333,6 +366,14 @@ func (h *AssignmentHandler) Delete(c *gin.Context) {
|
|||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
deleteRecurringStr := c.Query("stop_recurring")
|
||||||
|
if deleteRecurringStr != "" {
|
||||||
|
recurringID, err := strconv.ParseUint(deleteRecurringStr, 10, 32)
|
||||||
|
if err == nil {
|
||||||
|
h.recurringService.Delete(userID, uint(recurringID), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.assignmentService.Delete(userID, uint(id))
|
h.assignmentService.Delete(userID, uint(id))
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/assignments")
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
@@ -417,3 +458,162 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) {
|
|||||||
|
|
||||||
c.Redirect(http.StatusFound, "/statistics?include_archived=true")
|
c.Redirect(http.StatusFound, "/statistics?include_archived=true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) StopRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recurringService.SetActive(userID, uint(id), false)
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) ResumeRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recurringService.SetActive(userID, uint(id), true)
|
||||||
|
referer := c.Request.Referer()
|
||||||
|
if referer == "" {
|
||||||
|
referer = "/assignments"
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) ListRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
recurrings, err := h.recurringService.GetAllByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
recurrings = []models.RecurringAssignment{}
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "recurring/index.html", gin.H{
|
||||||
|
"title": "繰り返し設定一覧",
|
||||||
|
"recurrings": recurrings,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) EditRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recurring, err := h.recurringService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "recurring/edit.html", gin.H{
|
||||||
|
"title": "繰り返し課題の編集",
|
||||||
|
"recurring": recurring,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := c.PostForm("title")
|
||||||
|
description := c.PostForm("description")
|
||||||
|
subject := c.PostForm("subject")
|
||||||
|
priority := c.PostForm("priority")
|
||||||
|
recurrenceType := c.PostForm("recurrence_type")
|
||||||
|
dueTime := c.PostForm("due_time")
|
||||||
|
editBehavior := c.PostForm("edit_behavior")
|
||||||
|
|
||||||
|
recurrenceInterval := 1
|
||||||
|
if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 {
|
||||||
|
recurrenceInterval = v
|
||||||
|
}
|
||||||
|
|
||||||
|
var recurrenceWeekday *int
|
||||||
|
if wd := c.PostForm("recurrence_weekday"); wd != "" {
|
||||||
|
if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 {
|
||||||
|
recurrenceWeekday = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recurrenceDay *int
|
||||||
|
if d := c.PostForm("recurrence_day"); d != "" {
|
||||||
|
if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 31 {
|
||||||
|
recurrenceDay = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endType := c.PostForm("end_type")
|
||||||
|
var endCount *int
|
||||||
|
if ec := c.PostForm("end_count"); ec != "" {
|
||||||
|
if v, err := strconv.Atoi(ec); err == nil && v > 0 {
|
||||||
|
endCount = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var endDate *time.Time
|
||||||
|
if ed := c.PostForm("end_date"); ed != "" {
|
||||||
|
if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil {
|
||||||
|
endDate = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.UpdateRecurringInput{
|
||||||
|
Title: &title,
|
||||||
|
Description: &description,
|
||||||
|
Subject: &subject,
|
||||||
|
Priority: &priority,
|
||||||
|
RecurrenceType: &recurrenceType,
|
||||||
|
RecurrenceInterval: &recurrenceInterval,
|
||||||
|
RecurrenceWeekday: recurrenceWeekday,
|
||||||
|
RecurrenceDay: recurrenceDay,
|
||||||
|
DueTime: &dueTime,
|
||||||
|
EndType: &endType,
|
||||||
|
EndCount: endCount,
|
||||||
|
EndDate: endDate,
|
||||||
|
EditBehavior: editBehavior,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.recurringService.Update(userID, uint(id), input)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/recurring/"+c.Param("id")+"/edit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) DeleteRecurring(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recurringService.Delete(userID, uint(id), false)
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"homework-manager/internal/config"
|
||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
@@ -10,33 +11,96 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const twoFAPendingKey = "2fa_pending_user_id"
|
||||||
|
|
||||||
type AuthHandler struct {
|
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{
|
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) {
|
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
data := gin.H{"title": "ログイン"}
|
||||||
"title": "ログイン",
|
for k, v := range h.captchaData() {
|
||||||
})
|
data[k] = v
|
||||||
|
}
|
||||||
|
RenderHTML(c, http.StatusOK, "login.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
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)
|
user, err := h.authService.Login(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
renderLoginError("メールアドレスまたはパスワードが正しくありません")
|
||||||
"title": "ログイン",
|
return
|
||||||
"error": "メールアドレスまたはパスワードが正しくありません",
|
}
|
||||||
"email": email,
|
|
||||||
})
|
if user.TOTPEnabled {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(twoFAPendingKey, user.ID)
|
||||||
|
session.Save()
|
||||||
|
c.Redirect(http.StatusFound, "/login/2fa")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,35 +113,91 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
func (h *AuthHandler) ShowLogin2FA(c *gin.Context) {
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
session := sessions.Default(c)
|
||||||
"title": "新規登録",
|
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) {
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
passwordConfirm := c.PostForm("password_confirm")
|
passwordConfirm := c.PostForm("password_confirm")
|
||||||
name := c.PostForm("name")
|
name := c.PostForm("name")
|
||||||
|
|
||||||
if password != passwordConfirm {
|
renderRegisterError := func(msg string) {
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
data := gin.H{
|
||||||
"title": "新規登録",
|
"title": "新規登録",
|
||||||
"error": "パスワードが一致しません",
|
"error": msg,
|
||||||
"email": email,
|
"email": email,
|
||||||
"name": name,
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(password) < 8 {
|
if len(password) < 8 {
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
renderRegisterError("パスワードは8文字以上で入力してください")
|
||||||
"title": "新規登録",
|
|
||||||
"error": "パスワードは8文字以上で入力してください",
|
|
||||||
"email": email,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +207,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
if err == service.ErrEmailAlreadyExists {
|
if err == service.ErrEmailAlreadyExists {
|
||||||
errorMsg = "このメールアドレスは既に使用されています"
|
errorMsg = "このメールアドレスは既に使用されています"
|
||||||
}
|
}
|
||||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
renderRegisterError(errorMsg)
|
||||||
"title": "新規登録",
|
|
||||||
"error": errorMsg,
|
|
||||||
"email": email,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,23 @@ import (
|
|||||||
"homework-manager/internal/models"
|
"homework-manager/internal/models"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProfileHandler struct {
|
type ProfileHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
totpService *service.TOTPService
|
||||||
notificationService *service.NotificationService
|
notificationService *service.NotificationService
|
||||||
|
appName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
||||||
return &ProfileHandler{
|
return &ProfileHandler{
|
||||||
authService: service.NewAuthService(),
|
authService: service.NewAuthService(),
|
||||||
|
totpService: service.NewTOTPService(),
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
|
appName: "Super-HomeworkManager",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +148,6 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
|||||||
settings := &models.UserNotificationSettings{
|
settings := &models.UserNotificationSettings{
|
||||||
TelegramEnabled: c.PostForm("telegram_enabled") == "on",
|
TelegramEnabled: c.PostForm("telegram_enabled") == "on",
|
||||||
TelegramChatID: c.PostForm("telegram_chat_id"),
|
TelegramChatID: c.PostForm("telegram_chat_id"),
|
||||||
LineEnabled: c.PostForm("line_enabled") == "on",
|
|
||||||
LineNotifyToken: c.PostForm("line_token"),
|
|
||||||
NotifyOnCreate: c.PostForm("notify_on_create") == "on",
|
NotifyOnCreate: c.PostForm("notify_on_create") == "on",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,3 +176,135 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
|||||||
"notifySettings": notifySettings,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,12 +101,6 @@ func CSRF(config CSRFConfig) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
newToken, err := generateCSRFToken(config.Secret)
|
|
||||||
if err == nil {
|
|
||||||
session.Set(csrfTokenKey, newToken)
|
|
||||||
session.Save()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,21 +7,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
HTTPS bool
|
HTTPS bool
|
||||||
|
TurnstileEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if config.HTTPS {
|
if config.HTTPS {
|
||||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
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{
|
csp := []string{
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
"script-src " + scriptSrc,
|
||||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||||
"font-src 'self' https://cdn.jsdelivr.net",
|
"font-src 'self' https://cdn.jsdelivr.net",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data:",
|
||||||
"connect-src 'self'",
|
"connect-src " + connectSrc,
|
||||||
|
"frame-src " + frameSrc,
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
}
|
}
|
||||||
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type APIKey struct {
|
|||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
KeyHash string `gorm:"not null;uniqueIndex" json:"-"`
|
KeyHash string `gorm:"not null;uniqueIndex;size:255" json:"-"`
|
||||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserNotificationSettings struct {
|
type UserNotificationSettings struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||||
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
|
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
|
||||||
TelegramChatID string `json:"telegram_chat_id"`
|
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"`
|
||||||
NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
Email string `gorm:"uniqueIndex;not null;size:255" json:"email"`
|
||||||
PasswordHash string `gorm:"not null" json:"-"`
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
@@ -346,3 +346,59 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
|
|||||||
err := query.Distinct("subject").Pluck("subject", &subjects).Error
|
err := query.Distinct("subject").Pluck("subject", &subjects).Error
|
||||||
return subjects, err
|
return subjects, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||||
|
var assignments []models.Assignment
|
||||||
|
var totalCount int64
|
||||||
|
|
||||||
|
dbQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
if queryStr != "" {
|
||||||
|
dbQuery = dbQuery.Where("title LIKE ? OR description LIKE ?", "%"+queryStr+"%", "%"+queryStr+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if priority != "" {
|
||||||
|
dbQuery = dbQuery.Where("priority = ?", priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||||
|
weekLater := startOfDay.AddDate(0, 0, 7)
|
||||||
|
|
||||||
|
switch filter {
|
||||||
|
case "completed":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ?", true)
|
||||||
|
case "overdue":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
|
||||||
|
case "due_today":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, endOfDay)
|
||||||
|
case "due_this_week":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, weekLater)
|
||||||
|
case "recurring":
|
||||||
|
dbQuery = dbQuery.Where("recurring_assignment_id IS NOT NULL")
|
||||||
|
default:
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dbQuery.Count(&totalCount).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter == "completed" {
|
||||||
|
dbQuery = dbQuery.Order("completed_at DESC")
|
||||||
|
} else {
|
||||||
|
dbQuery = dbQuery.Order("due_date ASC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error
|
||||||
|
return assignments, totalCount, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
result = append(result, rec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/dchest/captcha"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -48,6 +49,19 @@ func getFuncMap() template.FuncMap {
|
|||||||
"recurringLabel": service.GetRecurrenceTypeLabel,
|
"recurringLabel": service.GetRecurrenceTypeLabel,
|
||||||
"endTypeLabel": service.GetEndTypeLabel,
|
"endTypeLabel": service.GetEndTypeLabel,
|
||||||
"recurringSummary": service.FormatRecurringSummary,
|
"recurringSummary": service.FormatRecurringSummary,
|
||||||
|
"derefInt": func(i *int) int {
|
||||||
|
if i == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *i
|
||||||
|
},
|
||||||
|
"seq": func(start, end int) []int {
|
||||||
|
var result []int
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
result = append(result, i)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +81,7 @@ func loadTemplates() (*template.Template, error) {
|
|||||||
{"web/templates/auth/*.html", ""},
|
{"web/templates/auth/*.html", ""},
|
||||||
{"web/templates/pages/*.html", ""},
|
{"web/templates/pages/*.html", ""},
|
||||||
{"web/templates/assignments/*.html", "assignments/"},
|
{"web/templates/assignments/*.html", "assignments/"},
|
||||||
|
{"web/templates/recurring/*.html", "recurring/"},
|
||||||
{"web/templates/admin/*.html", "admin/"},
|
{"web/templates/admin/*.html", "admin/"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +176,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
r.Use(middleware.RequestTimer())
|
r.Use(middleware.RequestTimer())
|
||||||
|
|
||||||
securityConfig := middleware.SecurityConfig{
|
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.SecurityHeaders(securityConfig))
|
||||||
r.Use(middleware.ForceHTTPS(securityConfig))
|
r.Use(middleware.ForceHTTPS(securityConfig))
|
||||||
@@ -182,11 +198,21 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
|
|
||||||
notificationService.StartReminderScheduler()
|
notificationService.StartReminderScheduler()
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler()
|
authHandler := handler.NewAuthHandler(cfg.Captcha)
|
||||||
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
profileHandler := handler.NewProfileHandler(notificationService)
|
profileHandler := handler.NewProfileHandler(notificationService)
|
||||||
apiHandler := handler.NewAPIHandler()
|
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 := r.Group("/")
|
||||||
guest.Use(middleware.GuestOnly())
|
guest.Use(middleware.GuestOnly())
|
||||||
@@ -226,10 +252,20 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject)
|
auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject)
|
||||||
auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject)
|
auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject)
|
||||||
|
|
||||||
|
auth.POST("/recurring/:id/stop", assignmentHandler.StopRecurring)
|
||||||
|
auth.POST("/recurring/:id/resume", assignmentHandler.ResumeRecurring)
|
||||||
|
auth.POST("/recurring/:id/delete", assignmentHandler.DeleteRecurring)
|
||||||
|
auth.GET("/recurring", assignmentHandler.ListRecurring)
|
||||||
|
auth.GET("/recurring/:id/edit", assignmentHandler.EditRecurring)
|
||||||
|
auth.POST("/recurring/:id", assignmentHandler.UpdateRecurring)
|
||||||
|
|
||||||
auth.GET("/profile", profileHandler.Show)
|
auth.GET("/profile", profileHandler.Show)
|
||||||
auth.POST("/profile", profileHandler.Update)
|
auth.POST("/profile", profileHandler.Update)
|
||||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||||
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
|
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 := auth.Group("/admin")
|
||||||
admin.Use(middleware.AdminRequired())
|
admin.Use(middleware.AdminRequired())
|
||||||
@@ -258,7 +294,13 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
||||||
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
||||||
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
||||||
|
|
||||||
api.GET("/statistics", apiHandler.GetStatistics)
|
api.GET("/statistics", apiHandler.GetStatistics)
|
||||||
|
|
||||||
|
api.GET("/recurring", apiRecurringHandler.ListRecurring)
|
||||||
|
api.GET("/recurring/:id", apiRecurringHandler.GetRecurring)
|
||||||
|
api.PUT("/recurring/:id", apiRecurringHandler.UpdateRecurring)
|
||||||
|
api.DELETE("/recurring/:id", apiRecurringHandler.DeleteRecurring)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
|||||||
pageSize = 10
|
pageSize = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
assignments, totalCount, err := s.assignmentRepo.Search(userID, query, priority, filter, page, pageSize)
|
assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -255,11 +255,11 @@ func (s *AssignmentService) GetSubjectsByUser(userID uint) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DashboardStats struct {
|
type DashboardStats struct {
|
||||||
TotalPending int64
|
TotalPending int64
|
||||||
DueToday int
|
DueToday int
|
||||||
DueThisWeek int
|
DueThisWeek int
|
||||||
Overdue int
|
Overdue int
|
||||||
Subjects []string
|
Subjects []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) {
|
func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) {
|
||||||
@@ -270,11 +270,11 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err
|
|||||||
subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID)
|
subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID)
|
||||||
|
|
||||||
return &DashboardStats{
|
return &DashboardStats{
|
||||||
TotalPending: pending,
|
TotalPending: pending,
|
||||||
DueToday: len(dueToday),
|
DueToday: len(dueToday),
|
||||||
DueThisWeek: len(dueThisWeek),
|
DueThisWeek: len(dueThisWeek),
|
||||||
Overdue: int(overdueCount),
|
Overdue: int(overdueCount),
|
||||||
Subjects: subjects,
|
Subjects: subjects,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,4 +392,3 @@ func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived
|
|||||||
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||||
return s.assignmentRepo.GetArchivedSubjects(userID)
|
return s.assignmentRepo.GetArchivedSubjects(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,3 +104,23 @@ func (s *AuthService) UpdateProfile(userID uint, name string) error {
|
|||||||
user.Name = name
|
user.Name = name
|
||||||
return s.userRepo.Update(user)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
75
internal/service/captcha_service.go
Normal file
75
internal/service/captcha_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -85,38 +84,6 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e
|
|||||||
return nil
|
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 {
|
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
|
||||||
settings, err := s.GetUserSettings(userID)
|
settings, err := s.GetUserSettings(userID)
|
||||||
if err != nil {
|
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 {
|
if len(errors) > 0 {
|
||||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||||
}
|
}
|
||||||
@@ -162,7 +123,7 @@ func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, ass
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !settings.TelegramEnabled && !settings.LineEnabled {
|
if !settings.TelegramEnabled {
|
||||||
return nil
|
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 {
|
if len(errors) > 0 {
|
||||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
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 {
|
if len(errors) > 0 {
|
||||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func NewRecurringAssignmentService() *RecurringAssignmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRecurringInput struct {
|
type CreateRecurringAssignmentInput struct {
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Subject string
|
Subject string
|
||||||
@@ -50,7 +50,7 @@ type CreateRecurringInput struct {
|
|||||||
FirstDueDate time.Time
|
FirstDueDate time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringInput) (*models.RecurringAssignment, error) {
|
func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAssignmentInput) (*models.RecurringAssignment, error) {
|
||||||
if !isValidRecurrenceType(input.RecurrenceType) {
|
if !isValidRecurrenceType(input.RecurrenceType) {
|
||||||
return nil, ErrInvalidRecurrenceType
|
return nil, ErrInvalidRecurrenceType
|
||||||
}
|
}
|
||||||
@@ -121,15 +121,22 @@ func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.Recu
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateRecurringInput struct {
|
type UpdateRecurringInput struct {
|
||||||
Title string
|
Title *string
|
||||||
Description string
|
Description *string
|
||||||
Subject string
|
Subject *string
|
||||||
Priority string
|
Priority *string
|
||||||
DueTime string
|
RecurrenceType *string
|
||||||
|
RecurrenceInterval *int
|
||||||
|
RecurrenceWeekday *int
|
||||||
|
RecurrenceDay *int
|
||||||
|
DueTime *string
|
||||||
|
EndType *string
|
||||||
|
EndCount *int
|
||||||
|
EndDate *time.Time
|
||||||
EditBehavior string
|
EditBehavior string
|
||||||
ReminderEnabled bool
|
ReminderEnabled *bool
|
||||||
ReminderOffset *int
|
ReminderOffset *int
|
||||||
UrgentReminderEnabled bool
|
UrgentReminderEnabled *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
|
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
|
||||||
@@ -138,19 +145,56 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
recurring.Title = input.Title
|
if input.Title != nil {
|
||||||
recurring.Description = input.Description
|
recurring.Title = *input.Title
|
||||||
recurring.Subject = input.Subject
|
}
|
||||||
recurring.Priority = input.Priority
|
if input.Description != nil {
|
||||||
if input.DueTime != "" {
|
recurring.Description = *input.Description
|
||||||
recurring.DueTime = input.DueTime
|
}
|
||||||
|
if input.Subject != nil {
|
||||||
|
recurring.Subject = *input.Subject
|
||||||
|
}
|
||||||
|
if input.Priority != nil {
|
||||||
|
recurring.Priority = *input.Priority
|
||||||
|
}
|
||||||
|
if input.DueTime != nil {
|
||||||
|
recurring.DueTime = *input.DueTime
|
||||||
}
|
}
|
||||||
if input.EditBehavior != "" {
|
if input.EditBehavior != "" {
|
||||||
recurring.EditBehavior = input.EditBehavior
|
recurring.EditBehavior = input.EditBehavior
|
||||||
}
|
}
|
||||||
recurring.ReminderEnabled = input.ReminderEnabled
|
if input.ReminderEnabled != nil {
|
||||||
recurring.ReminderOffset = input.ReminderOffset
|
recurring.ReminderEnabled = *input.ReminderEnabled
|
||||||
recurring.UrgentReminderEnabled = input.UrgentReminderEnabled
|
}
|
||||||
|
if input.ReminderOffset != nil {
|
||||||
|
recurring.ReminderOffset = input.ReminderOffset
|
||||||
|
}
|
||||||
|
if input.UrgentReminderEnabled != nil {
|
||||||
|
recurring.UrgentReminderEnabled = *input.UrgentReminderEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.RecurrenceType != nil && *input.RecurrenceType != "" && isValidRecurrenceType(*input.RecurrenceType) {
|
||||||
|
recurring.RecurrenceType = *input.RecurrenceType
|
||||||
|
}
|
||||||
|
if input.RecurrenceInterval != nil && *input.RecurrenceInterval > 0 {
|
||||||
|
recurring.RecurrenceInterval = *input.RecurrenceInterval
|
||||||
|
}
|
||||||
|
if input.RecurrenceWeekday != nil {
|
||||||
|
recurring.RecurrenceWeekday = input.RecurrenceWeekday
|
||||||
|
}
|
||||||
|
if input.RecurrenceDay != nil {
|
||||||
|
recurring.RecurrenceDay = input.RecurrenceDay
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.EndType != nil && isValidEndType(*input.EndType) {
|
||||||
|
recurring.EndType = *input.EndType
|
||||||
|
}
|
||||||
|
if input.EndCount != nil {
|
||||||
|
recurring.EndCount = input.EndCount
|
||||||
|
}
|
||||||
|
if input.EndDate != nil {
|
||||||
|
recurring.EndDate = input.EndDate
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.recurringRepo.Update(recurring); err != nil {
|
if err := s.recurringRepo.Update(recurring); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -159,6 +203,16 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda
|
|||||||
return recurring, nil
|
return recurring, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RecurringAssignmentService) SetActive(userID, recurringID uint, isActive bool) error {
|
||||||
|
recurring, err := s.GetByID(userID, recurringID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
recurring.IsActive = isActive
|
||||||
|
return s.recurringRepo.Update(recurring)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
|
func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
|
||||||
userID uint,
|
userID uint,
|
||||||
assignment *models.Assignment,
|
assignment *models.Assignment,
|
||||||
|
|||||||
71
internal/service/totp_service.go
Normal file
71
internal/service/totp_service.go
Normal 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)
|
||||||
|
}
|
||||||
173
internal/validation/validation.go
Normal file
173
internal/validation/validation.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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() {
|
setTextSafe: function (element, text) {
|
||||||
// Auto-dismiss alerts after 5 seconds
|
if (element) {
|
||||||
const alerts = document.querySelectorAll('.alert:not(.alert-danger)');
|
element.textContent = text;
|
||||||
alerts.forEach(function(alert) {
|
}
|
||||||
setTimeout(function() {
|
},
|
||||||
|
|
||||||
|
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 () {
|
||||||
alert.classList.add('fade');
|
alert.classList.add('fade');
|
||||||
setTimeout(function() {
|
setTimeout(function () {
|
||||||
alert.remove();
|
alert.remove();
|
||||||
}, 150);
|
}, 150);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirm dialogs for dangerous actions
|
|
||||||
const confirmForms = document.querySelectorAll('form[data-confirm]');
|
const confirmForms = document.querySelectorAll('form[data-confirm]');
|
||||||
confirmForms.forEach(function(form) {
|
confirmForms.forEach(function (form) {
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function (e) {
|
||||||
if (!confirm(form.dataset.confirm)) {
|
if (!confirm(form.dataset.confirm)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default datetime to now + 1 day for new assignments
|
|
||||||
const dueDateInput = document.getElementById('due_date');
|
const dueDateInput = document.getElementById('due_date');
|
||||||
if (dueDateInput && !dueDateInput.value) {
|
if (dueDateInput && !dueDateInput.value) {
|
||||||
const tomorrow = new Date();
|
const tomorrow = new Date();
|
||||||
|
|||||||
@@ -98,6 +98,11 @@
|
|||||||
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
|
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
|
||||||
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
|
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
|
||||||
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
|
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
|
||||||
|
<li><code>GET /api/v1/statistics</code> - 統計情報取得</li>
|
||||||
|
<li><code>GET /api/v1/recurring</code> - 繰り返し設定一覧取得</li>
|
||||||
|
<li><code>GET /api/v1/recurring/:id</code> - 繰り返し設定詳細取得</li>
|
||||||
|
<li><code>PUT /api/v1/recurring/:id</code> - 繰り返し設定更新</li>
|
||||||
|
<li><code>DELETE /api/v1/recurring/:id</code> - 繰り返し設定削除</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .recurring}}
|
||||||
|
<!-- 繰り返し設定 -->
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
|
||||||
|
<a href="/recurring/{{.recurring.ID}}/edit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>編集
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<small class="text-muted">タイプ</small>
|
||||||
|
<div class="fw-bold">
|
||||||
|
{{if eq .recurring.RecurrenceType "daily"}}毎日{{end}}
|
||||||
|
{{if eq .recurring.RecurrenceType "weekly"}}毎週{{end}}
|
||||||
|
{{if eq .recurring.RecurrenceType "monthly"}}毎月{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<small class="text-muted">状態</small>
|
||||||
|
<div>
|
||||||
|
{{if .recurring.IsActive}}
|
||||||
|
<span class="badge bg-success">有効</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-secondary">停止中</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
|
|||||||
@@ -56,6 +56,13 @@
|
|||||||
期限切れ
|
期限切れ
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-2 rounded-0 border-0 text-muted"
|
||||||
|
href="/recurring">
|
||||||
|
繰り返し
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
||||||
|
|
||||||
@@ -92,9 +99,9 @@
|
|||||||
<thead class="bg-secondary-subtle">
|
<thead class="bg-secondary-subtle">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
|
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
|
||||||
<th class="text-dark fw-bold">タイトル</th>
|
|
||||||
<th style="width: 120px;" class="text-dark fw-bold">科目</th>
|
<th style="width: 120px;" class="text-dark fw-bold">科目</th>
|
||||||
<th style="width: 80px;" class="text-dark fw-bold">重要度</th>
|
<th style="width: 80px;" class="text-dark fw-bold">重要度</th>
|
||||||
|
<th class="text-dark fw-bold">タイトル</th>
|
||||||
<th style="width: 140px;" class="text-dark fw-bold">期限</th>
|
<th style="width: 140px;" class="text-dark fw-bold">期限</th>
|
||||||
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||||
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
||||||
@@ -125,9 +132,6 @@
|
|||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Priority "high"}}
|
{{if eq .Priority "high"}}
|
||||||
@@ -138,6 +142,22 @@
|
|||||||
<span class="badge bg-dark text-white border-0 fw-bold small">低</span>
|
<span class="badge bg-dark text-white border-0 fw-bold small">低</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="fw-bold text-dark text-truncate" style="max-width: 280px;">{{.Title}}</div>
|
||||||
|
{{if .RecurringAssignmentID}}
|
||||||
|
<button type="button" class="btn btn-link p-0 ms-2 text-info" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#recurringModal" data-recurring-id="{{.RecurringAssignmentID}}"
|
||||||
|
data-assignment-id="{{.ID}}"
|
||||||
|
data-recurring-title="{{.Title}}"
|
||||||
|
data-recurring-type="{{if .RecurringAssignment}}{{.RecurringAssignment.RecurrenceType}}{{else}}unknown{{end}}"
|
||||||
|
data-recurring-active="{{if .RecurringAssignment}}{{.RecurringAssignment.IsActive}}{{else}}true{{end}}"
|
||||||
|
title="繰り返し課題">
|
||||||
|
<i class="fa-solid fa-repeat"></i>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
|
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +174,12 @@
|
|||||||
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
|
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
|
||||||
<i class="bi bi-pencil-fill"></i>
|
<i class="bi bi-pencil-fill"></i>
|
||||||
</a>
|
</a>
|
||||||
|
{{if .RecurringAssignmentID}}
|
||||||
|
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent"
|
||||||
|
onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
|
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
|
||||||
onsubmit="return confirm('削除しますか?');">
|
onsubmit="return confirm('削除しますか?');">
|
||||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
@@ -162,6 +188,7 @@
|
|||||||
<i class="bi bi-trash-fill"></i>
|
<i class="bi bi-trash-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -290,5 +317,126 @@
|
|||||||
const btnText = document.getElementById('countdownBtnText');
|
const btnText = document.getElementById('countdownBtnText');
|
||||||
if (btnText) btnText.textContent = 'カウントダウン非表示中';
|
if (btnText) btnText.textContent = 'カウントダウン非表示中';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurring modal handler - wait for DOM to be ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const recurringModal = document.getElementById('recurringModal');
|
||||||
|
if (recurringModal) {
|
||||||
|
recurringModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
const id = button.getAttribute('data-recurring-id');
|
||||||
|
const assignmentId = button.getAttribute('data-assignment-id');
|
||||||
|
const title = button.getAttribute('data-recurring-title');
|
||||||
|
const type = button.getAttribute('data-recurring-type');
|
||||||
|
const isActive = button.getAttribute('data-recurring-active') === 'true';
|
||||||
|
|
||||||
|
document.getElementById('recurringModalTitle').textContent = title;
|
||||||
|
document.getElementById('recurringStopForm').action = '/recurring/' + id + '/stop';
|
||||||
|
document.getElementById('recurringEditBtn').href = '/recurring/' + id + '/edit';
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
'daily': '毎日',
|
||||||
|
'weekly': '毎週',
|
||||||
|
'monthly': '毎月',
|
||||||
|
'unknown': '(読み込み中...)'
|
||||||
|
};
|
||||||
|
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
|
||||||
|
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('recurringStatus');
|
||||||
|
if (isActive) {
|
||||||
|
statusEl.innerHTML = '<span class="badge bg-success">有効</span>';
|
||||||
|
document.getElementById('recurringStopBtn').style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = '<span class="badge bg-secondary">停止中</span>';
|
||||||
|
document.getElementById('recurringStopBtn').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Recurring Modal -->
|
||||||
|
<div class="modal fade" id="recurringModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="fa-solid fa-repeat me-2"></i>繰り返し課題</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<h6 id="recurringModalTitle" class="mb-3 fw-bold"></h6>
|
||||||
|
<table class="table table-sm table-borderless">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th class="text-muted" style="width: 100px;">繰り返し</th>
|
||||||
|
<td id="recurringTypeLabel">読み込み中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="text-muted">状態</th>
|
||||||
|
<td id="recurringStatus">読み込み中...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="alert alert-info small mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
繰り返しを停止すると、今後新しい課題は自動作成されなくなります。既存の課題はそのまま残ります。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">閉じる</button>
|
||||||
|
<a id="recurringEditBtn" href="#" class="btn btn-primary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>編集
|
||||||
|
</a>
|
||||||
|
<form id="recurringStopForm" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||||
|
<button type="submit" id="recurringStopBtn" class="btn btn-danger"
|
||||||
|
onclick="return confirm('繰り返しを停止しますか?');">
|
||||||
|
<i class="bi bi-stop-fill me-1"></i>停止
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Recurring Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteRecurringModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>繰り返し課題の削除</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>この課題は繰り返し設定に関連付けられています。</p>
|
||||||
|
<p>繰り返し設定も停止しますか?</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
|
||||||
|
<form id="deleteOnlyForm" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">
|
||||||
|
課題のみ削除
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form id="deleteAndStopForm" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
削除して繰り返しも削除
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showDeleteRecurringModal(assignmentId, recurringId) {
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('deleteRecurringModal'));
|
||||||
|
document.getElementById('deleteOnlyForm').action = '/assignments/' + assignmentId + '/delete';
|
||||||
|
document.getElementById('deleteAndStopForm').action = '/assignments/' + assignmentId + '/delete?stop_recurring=' + recurringId;
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -101,25 +101,25 @@
|
|||||||
<label class="form-label small">曜日</label>
|
<label class="form-label small">曜日</label>
|
||||||
<div class="btn-group btn-group-sm w-100" role="group">
|
<div class="btn-group btn-group-sm w-100" role="group">
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
|
||||||
value="0">
|
value="0" {{if eq .currentWeekday 0}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd0">日</label>
|
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
|
||||||
value="1" checked>
|
value="1" {{if eq .currentWeekday 1}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd1">月</label>
|
<label class="btn btn-outline-primary" for="wd1">月</label>
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
|
||||||
value="2">
|
value="2" {{if eq .currentWeekday 2}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd2">火</label>
|
<label class="btn btn-outline-primary" for="wd2">火</label>
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
|
||||||
value="3">
|
value="3" {{if eq .currentWeekday 3}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd3">水</label>
|
<label class="btn btn-outline-primary" for="wd3">水</label>
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
|
||||||
value="4">
|
value="4" {{if eq .currentWeekday 4}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd4">木</label>
|
<label class="btn btn-outline-primary" for="wd4">木</label>
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
|
||||||
value="5">
|
value="5" {{if eq .currentWeekday 5}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd5">金</label>
|
<label class="btn btn-outline-primary" for="wd5">金</label>
|
||||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
|
||||||
value="6">
|
value="6" {{if eq .currentWeekday 6}}checked{{end}}>
|
||||||
<label class="btn btn-outline-primary" for="wd6">土</label>
|
<label class="btn btn-outline-primary" for="wd6">土</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,37 +127,9 @@
|
|||||||
<label for="recurrence_day" class="form-label small">日</label>
|
<label for="recurrence_day" class="form-label small">日</label>
|
||||||
<select class="form-select form-select-sm" id="recurrence_day"
|
<select class="form-select form-select-sm" id="recurrence_day"
|
||||||
name="recurrence_day">
|
name="recurrence_day">
|
||||||
<option value="1">1日</option>
|
{{range $i := seq 1 31}}
|
||||||
<option value="2">2日</option>
|
<option value="{{$i}}" {{if eq $.currentDay $i}}selected{{end}}>{{$i}}日</option>
|
||||||
<option value="3">3日</option>
|
{{end}}
|
||||||
<option value="4">4日</option>
|
|
||||||
<option value="5">5日</option>
|
|
||||||
<option value="6">6日</option>
|
|
||||||
<option value="7">7日</option>
|
|
||||||
<option value="8">8日</option>
|
|
||||||
<option value="9">9日</option>
|
|
||||||
<option value="10">10日</option>
|
|
||||||
<option value="11">11日</option>
|
|
||||||
<option value="12">12日</option>
|
|
||||||
<option value="13">13日</option>
|
|
||||||
<option value="14">14日</option>
|
|
||||||
<option value="15">15日</option>
|
|
||||||
<option value="16">16日</option>
|
|
||||||
<option value="17">17日</option>
|
|
||||||
<option value="18">18日</option>
|
|
||||||
<option value="19">19日</option>
|
|
||||||
<option value="20">20日</option>
|
|
||||||
<option value="21">21日</option>
|
|
||||||
<option value="22">22日</option>
|
|
||||||
<option value="23">23日</option>
|
|
||||||
<option value="24">24日</option>
|
|
||||||
<option value="25">25日</option>
|
|
||||||
<option value="26">26日</option>
|
|
||||||
<option value="27">27日</option>
|
|
||||||
<option value="28">28日</option>
|
|
||||||
<option value="29">29日</option>
|
|
||||||
<option value="30">30日</option>
|
|
||||||
<option value="31">31日</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="end_group" style="display: none;">
|
<div id="end_group" style="display: none;">
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{{template "base" .}}
|
{{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"}}
|
{{define "content"}}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-5 col-lg-4">
|
<div class="col-md-5 col-lg-4">
|
||||||
@@ -25,6 +31,28 @@
|
|||||||
<label for="password" class="form-label">パスワード</label>
|
<label for="password" class="form-label">パスワード</label>
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
</div>
|
</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">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,4 +68,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}}
|
||||||
|
|||||||
46
web/templates/auth/login_2fa.html
Normal file
46
web/templates/auth/login_2fa.html
Normal 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}}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{{template "base" .}}
|
{{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"}}
|
{{define "content"}}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-5 col-lg-4">
|
<div class="col-md-5 col-lg-4">
|
||||||
@@ -28,14 +34,36 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">パスワード</label>
|
<label for="password" class="form-label">パスワード</label>
|
||||||
<input type="password" class="form-control" id="password" name="password" required
|
<input type="password" class="form-control" id="password" name="password" required
|
||||||
minlength="6">
|
minlength="8">
|
||||||
<div class="form-text">6文字以上</div>
|
<div class="form-text">8文字以上</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password_confirm" class="form-label">パスワード(確認)</label>
|
<label for="password_confirm" class="form-label">パスワード(確認)</label>
|
||||||
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
||||||
required minlength="6">
|
required minlength="8">
|
||||||
</div>
|
</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">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,4 +79,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<title>{{.title}} - Super Homework Manager</title>
|
<title>{{.title}} - Super Homework Manager</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" rel="stylesheet">
|
||||||
<link href="/static/css/style.css" rel="stylesheet">
|
<link href="/static/css/style.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.navbar-dark .navbar-nav .nav-link,
|
.navbar-dark .navbar-nav .nav-link,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
<i class="bi bi-exclamation-octagon-fill me-2"></i>
|
<i class="bi bi-exclamation-octagon-fill me-2"></i>
|
||||||
<span id="urgentMessage"></span>
|
<span id="urgentMessage"></span>
|
||||||
<div class="urgent-countdown mt-1">
|
<div class="urgent-countdown mt-1">
|
||||||
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span>
|
<i class="bi bi-stopwatch"></i> <span id="urgentCountdown"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,9 +164,9 @@
|
|||||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||||
<div>
|
<div>
|
||||||
|
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||||
|
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||||
<strong>{{.Title}}</strong>
|
<strong>{{.Title}}</strong>
|
||||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
|
||||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
|
||||||
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
|
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
|
||||||
</div>
|
</div>
|
||||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||||
@@ -187,9 +187,9 @@
|
|||||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||||
<div>
|
<div>
|
||||||
|
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||||
|
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||||
<strong>{{.Title}}</strong>
|
<strong>{{.Title}}</strong>
|
||||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
|
||||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
|
||||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||||
</div>
|
</div>
|
||||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||||
@@ -210,9 +210,9 @@
|
|||||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||||
<div>
|
<div>
|
||||||
|
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||||
|
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||||
<strong>{{.Title}}</strong>
|
<strong>{{.Title}}</strong>
|
||||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
|
||||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
|
||||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||||
</div>
|
</div>
|
||||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||||
@@ -306,10 +306,10 @@
|
|||||||
var mins = Math.floor((diff % 3600000) / 60000);
|
var mins = Math.floor((diff % 3600000) / 60000);
|
||||||
var secs = Math.floor((diff % 60000) / 1000);
|
var secs = Math.floor((diff % 60000) / 1000);
|
||||||
|
|
||||||
var text = '';
|
var text = 'あと ';
|
||||||
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
|
if (days > 0) text += days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||||
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒';
|
else if (hours > 0) text += hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||||
else text = mins + '分 ' + secs + '秒';
|
else text += mins + '分 ' + secs + '秒';
|
||||||
|
|
||||||
countdown.textContent = text;
|
countdown.textContent = text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -95,22 +132,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
|
|||||||
83
web/templates/pages/totp_setup.html
Normal file
83
web/templates/pages/totp_setup.html
Normal 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}}
|
||||||
148
web/templates/recurring/edit.html
Normal file
148
web/templates/recurring/edit.html
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>繰り返し課題の編集</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/recurring/{{.recurring.ID}}">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title" value="{{.recurring.Title}}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="subject" class="form-label">科目</label>
|
||||||
|
<input type="text" class="form-control" id="subject" name="subject" value="{{.recurring.Subject}}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="priority" class="form-label">重要度</label>
|
||||||
|
<select class="form-select" id="priority" name="priority">
|
||||||
|
<option value="low" {{if eq .recurring.Priority "low"}}selected{{end}}>小</option>
|
||||||
|
<option value="medium" {{if eq .recurring.Priority "medium"}}selected{{end}}>中</option>
|
||||||
|
<option value="high" {{if eq .recurring.Priority "high"}}selected{{end}}>大</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">説明</label>
|
||||||
|
<textarea class="form-control" id="description" name="description" rows="3">{{.recurring.Description}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="due_time" class="form-label">時刻</label>
|
||||||
|
<input type="time" class="form-control" id="due_time" name="due_time" value="{{.recurring.DueTime}}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<h6 class="mb-3"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
|
||||||
|
<select class="form-select form-select-sm" id="recurrence_type" name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||||
|
<option value="daily" {{if eq .recurring.RecurrenceType "daily"}}selected{{end}}>毎日</option>
|
||||||
|
<option value="weekly" {{if eq .recurring.RecurrenceType "weekly"}}selected{{end}}>毎週</option>
|
||||||
|
<option value="monthly" {{if eq .recurring.RecurrenceType "monthly"}}selected{{end}}>毎月</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="recurrence_interval" class="form-label small">間隔</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="{{.recurring.RecurrenceInterval}}" min="1" max="12">
|
||||||
|
<span class="input-group-text" id="interval_label">{{if eq .recurring.RecurrenceType "daily"}}日{{else if eq .recurring.RecurrenceType "weekly"}}週{{else}}月{{end}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="weekday_group" class="mb-3">
|
||||||
|
<label class="form-label small">曜日</label>
|
||||||
|
<div class="btn-group btn-group-sm w-100" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" value="0" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 0}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" value="1" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 1}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd1">月</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2" value="2" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 2}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd2">火</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3" value="3" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 3}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd3">水</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4" value="4" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 4}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd4">木</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5" value="5" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 5}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd5">金</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6" value="6" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 6}}checked{{end}}{{end}}>
|
||||||
|
<label class="btn btn-outline-primary" for="wd6">土</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="day_group" class="mb-3">
|
||||||
|
<label for="recurrence_day" class="form-label small">日</label>
|
||||||
|
<select class="form-select form-select-sm" id="recurrence_day" name="recurrence_day">
|
||||||
|
{{range $i := seq 1 31}}
|
||||||
|
<option value="{{$i}}" {{if $.recurring.RecurrenceDay}}{{if eq (derefInt $.recurring.RecurrenceDay) $i}}selected{{end}}{{end}}>{{$i}}日</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="small text-muted">状態:</span>
|
||||||
|
{{if .recurring.IsActive}}
|
||||||
|
<span class="badge bg-success">有効</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-secondary">停止中</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||||
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
|
{{if .recurring.IsActive}}
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="if(confirm('繰り返しを停止しますか?今後新しい課題は自動作成されなくなります。')) document.getElementById('stopForm').submit();">
|
||||||
|
<i class="bi bi-stop-fill me-1"></i>停止
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="button" class="btn btn-outline-success" onclick="document.getElementById('resumeForm').submit();">
|
||||||
|
<i class="bi bi-play-fill me-1"></i>再開
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" class="btn btn-outline-danger ms-auto" onclick="if(confirm('この繰り返し設定を削除しますか?この操作は取り消せません。')) document.getElementById('deleteForm').submit();">
|
||||||
|
<i class="bi bi-trash me-1"></i>削除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .recurring.IsActive}}
|
||||||
|
<form id="stopForm" action="/recurring/{{.recurring.ID}}/stop" method="POST" class="d-none">
|
||||||
|
{{.csrfField}}
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form id="resumeForm" action="/recurring/{{.recurring.ID}}/resume" method="POST" class="d-none">
|
||||||
|
{{.csrfField}}
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form id="deleteForm" action="/recurring/{{.recurring.ID}}/delete" method="POST" class="d-none">
|
||||||
|
{{.csrfField}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateRecurrenceOptions() {
|
||||||
|
var type = document.getElementById('recurrence_type').value;
|
||||||
|
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
|
||||||
|
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
|
||||||
|
var label = document.getElementById('interval_label');
|
||||||
|
if (type === 'daily') label.textContent = '日';
|
||||||
|
else if (type === 'weekly') label.textContent = '週';
|
||||||
|
else if (type === 'monthly') label.textContent = '月';
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateRecurrenceOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
68
web/templates/recurring/index.html
Normal file
68
web/templates/recurring/index.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h4 class="mb-0 fw-bold"><i class="bi bi-arrow-repeat me-2"></i>繰り返し設定一覧</h4>
|
||||||
|
</div>
|
||||||
|
<a href="/assignments" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">タイトル</th>
|
||||||
|
<th>科目</th>
|
||||||
|
<th>繰り返し</th>
|
||||||
|
<th>状態</th>
|
||||||
|
<th class="text-end pe-3">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .recurrings}}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-bold">{{.Title}}</div>
|
||||||
|
{{if .Description}}
|
||||||
|
<div class="text-muted small text-truncate" style="max-width: 200px;">{{.Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .Subject}}
|
||||||
|
<span class="badge bg-secondary">{{.Subject}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-dark">{{recurringSummary .}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .IsActive}}
|
||||||
|
<span class="badge bg-success">有効</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-secondary">停止中</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<a href="/recurring/{{.ID}}/edit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4 text-muted">
|
||||||
|
繰り返し設定がありません
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user