From b99d3521852369dec6092774b8476df05ac5ec6b Mon Sep 17 00:00:00 2001 From: dendencat Date: Tue, 16 Sep 2025 20:46:35 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=E4=B8=8D=E8=A6=81=E3=81=A8?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/ssl/README | 14 ------- techblog_cms/migrations/0001_initial.py | 51 ------------------------- techblog_cms/migrations/__init__.py | 0 3 files changed, 65 deletions(-) delete mode 100644 nginx/ssl/README delete mode 100644 techblog_cms/migrations/0001_initial.py delete mode 100644 techblog_cms/migrations/__init__.py diff --git a/nginx/ssl/README b/nginx/ssl/README deleted file mode 100644 index 5050078..0000000 --- a/nginx/ssl/README +++ /dev/null @@ -1,14 +0,0 @@ -This directory contains your keys and certificates. - -`privkey.pem` : the private key for your certificate. -`fullchain.pem`: the certificate file used in most server software. -`chain.pem` : used for OCSP stapling in Nginx >=1.3.7. -`cert.pem` : will break many server configurations, and should not be used - without reading further documentation (see link below). - -WARNING: DO NOT MOVE OR RENAME THESE FILES! - Certbot expects these files to remain in this location in order - to function properly! - -We recommend not moving these files. For more information, see the Certbot -User Guide at https://certbot.eff.org/docs/using.html#where-are-my-certificates. diff --git a/techblog_cms/migrations/0001_initial.py b/techblog_cms/migrations/0001_initial.py deleted file mode 100644 index 424bc2d..0000000 --- a/techblog_cms/migrations/0001_initial.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 4.2.10 on 2025-08-31 09:06 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Category', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('slug', models.SlugField(unique=True)), - ('description', models.TextField(blank=True)), - ], - options={ - 'verbose_name_plural': 'categories', - }, - ), - migrations.CreateModel( - name='Tag', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ('slug', models.SlugField(unique=True)), - ], - ), - migrations.CreateModel( - name='Article', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200)), - ('slug', models.SlugField(unique=True)), - ('content', models.TextField()), - ('excerpt', models.TextField(blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('published', models.BooleanField(default=False)), - ('image', models.ImageField(blank=True, null=True, upload_to='articles/')), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='techblog_cms.category')), - ('tags', models.ManyToManyField(blank=True, to='techblog_cms.tag')), - ], - ), - ] diff --git a/techblog_cms/migrations/__init__.py b/techblog_cms/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 From 36b33acfb8e5a3c37e2589a9915fbb42a422ec39 Mon Sep 17 00:00:00 2001 From: dendencat Date: Tue, 16 Sep 2025 20:51:18 +0900 Subject: [PATCH 2/5] Refactor settings and enhance security features - Updated settings.py to use SQLite for testing and added password validation. - Introduced production settings in settings_production.py with enhanced security configurations. - Improved CSS for mobile menu and sidebar responsiveness. - Enhanced JavaScript for mobile menu toggle functionality. - Updated article detail and editor templates to show updated timestamps and handle article editing more intuitively. - Added tag functionality with new templates for tag listing and detail views. - Implemented login security tests to prevent username enumeration attacks. - Refactored views to include categories and tags in context for various templates. - Added logging configuration for better error tracking and monitoring. - Created tests for article editing and markdown rendering. --- .env.example | 19 +- .github/workflows/claude-code-review.yml | 1 + .github/workflows/copilot-instructions.md | 77 +++++ .gitignore | 5 +- AGENTS.md | 200 +++++------ docker-compose.override.yml.example | 34 ++ docker-compose.yml | 17 +- docs/CONFIGURATION.md | 324 ++++++++++++++++++ docs/QUICK_CONFIG_REFERENCE.md | 128 +++++++ requirements.txt | 56 ++- scripts/production_checklist.sh | 112 ++++++ techblog_cms/health.py | 58 ++++ techblog_cms/management/__init__.py | 0 techblog_cms/management/commands/__init__.py | 0 techblog_cms/management/commands/backup_db.py | 90 +++++ techblog_cms/models.py | 29 +- techblog_cms/settings.py | 157 ++++++++- techblog_cms/settings_production.py | 59 ++++ techblog_cms/static/css/style.css | 58 +++- techblog_cms/static/js/main.js | 66 +++- techblog_cms/templates/article_detail.html | 5 +- techblog_cms/templates/article_editor.html | 11 +- techblog_cms/templates/base.html | 8 +- techblog_cms/templates/components/header.html | 55 +-- .../templates/components/sidebar.html | 2 +- techblog_cms/templates/dashboard.html | 4 + techblog_cms/templates/tag_detail.html | 34 ++ techblog_cms/templates/tag_list.html | 22 ++ techblog_cms/templatetags/markdown_filter.py | 5 +- techblog_cms/tests/test_article_editing.py | 74 ++++ techblog_cms/tests/test_login_security.py | 93 +++++ techblog_cms/urls.py | 7 + techblog_cms/views.py | 171 ++++++--- 33 files changed, 1780 insertions(+), 201 deletions(-) create mode 100644 .github/workflows/copilot-instructions.md create mode 100644 docker-compose.override.yml.example create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/QUICK_CONFIG_REFERENCE.md create mode 100755 scripts/production_checklist.sh create mode 100644 techblog_cms/health.py create mode 100644 techblog_cms/management/__init__.py create mode 100644 techblog_cms/management/commands/__init__.py create mode 100644 techblog_cms/management/commands/backup_db.py create mode 100644 techblog_cms/settings_production.py create mode 100644 techblog_cms/templates/tag_detail.html create mode 100644 techblog_cms/templates/tag_list.html create mode 100644 techblog_cms/tests/test_article_editing.py create mode 100644 techblog_cms/tests/test_login_security.py diff --git a/.env.example b/.env.example index c820134..b4d025d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ +# Core Settings DEBUG=False SECRET_KEY=your-secret-key-here -ALLOWED_HOSTS=.localhost,127.0.0.1 +ALLOWED_HOSTS=.localhost,127.0.0.1,yourdomain.com # Database (choose one of the following approaches) # 1) DATABASE_URL (recommended for production; supports URLエンコード済みパスワード) @@ -17,6 +18,7 @@ POSTGRES_DB=your-db-name # APP_DB_PASSWORD=app-strong-password # APP_DB_NAME=appdb +# Redis Configuration REDIS_PASSWORD=your-redis-password REDIS_URL=redis://:your-redis-password@redis:6379/1 @@ -31,3 +33,18 @@ CSRF_TRUSTED_ORIGINS=https://yourdomain.com SECURE_SSL_REDIRECT=True SESSION_COOKIE_SECURE=True CSRF_COOKIE_SECURE=True + +# Email Configuration (Production) +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=your-email@gmail.com +EMAIL_HOST_PASSWORD=your-app-password +DEFAULT_FROM_EMAIL=noreply@yourdomain.com +ADMIN_EMAIL=admin@yourdomain.com + +# Domain Configuration +DOMAIN=yourdomain.com + +# Sentry Error Tracking (Optional) +# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4caf96a..7211c7f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -35,6 +35,7 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: + github_token: ${{ secrets.GITHUB_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | Please review this pull request and provide feedback on: diff --git a/.github/workflows/copilot-instructions.md b/.github/workflows/copilot-instructions.md new file mode 100644 index 0000000..7499d5e --- /dev/null +++ b/.github/workflows/copilot-instructions.md @@ -0,0 +1,77 @@ +--- +description: TechBlog CMS repository-wide Copilot instructions +mode: agent +tools: ['extensions', 'codebase', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'terminalSelection', 'terminalLastCommand', 'openSimpleBrowser', 'fetch', 'findTestFiles', 'searchResults', 'githubRepo', 'getPythonEnvironmentInfo', 'getPythonExecutableCommand', 'installPythonPackage', 'configurePythonEnvironment', 'runTests', 'runCommands', 'runTasks', 'editFiles', 'runNotebooks', 'search'] +model: Grok Code Fast 1 (Preview) +--- +# .github/copilot-instructions.md + +> Copilot Chat / Code Review / Coding Agent が参照する共通ルール。断定形・簡潔。 +> このリポジトリは Django 4.2 + Python 3.11 の技術ブログ CMS。Docker Compose で Nginx/PG/Redis を統合。 + +## Project facts +- ランタイム: Python 3.11 / Django 4.2 +- Web/WSGI: Gunicorn / WSGI (`techblog_cms.wsgi`) +- Webサーバ: Nginx(HTTPS, セキュリティヘッダー) +- DB/キャッシュ: PostgreSQL 16 / Redis 7 +- テスト: pytest, pytest-django, Playwright(E2E) +- コンテナ: Docker, Docker Compose +- 静的/メディア: Nginx 配信(Django は DEBUG 時のみ) + +## How to build, test, run +- 推奨: Docker Compose + - 起動: `docker compose up -d` + - 初期化は `docker/entrypoint.sh` が担当(migrate, collectstatic) + - Djangoコマンド: `docker compose exec django python manage.py ` +- ローカル(必要時) + - 依存: `pip install -r requirements.txt` + - DB設定: `.env.example` を `.env` にコピーし値を設定 + - マイグレーション: `python manage.py migrate` + - 起動: `gunicorn --config gunicorn.conf.py techblog_cms.wsgi:application` +- テスト + - ユニット: `pytest -v` + - E2E: Playwright 依存をインストール後、サーバ起動状態で実行 + +## Repository structure(前提) +- `techblog_cms/`: 主要 Django アプリ(正系統) +- `app/techblog_cms/`: 重複ツリー。新規コードは作らない。段階的に統合・撤去。 +- `nginx/`, `docker/`, `scripts/`: インフラ/運用 +- `_note/`: レビュー/要件/ToDo ドキュメント +- 静的資産は `techblog_cms/static/` のみを使用。`templates/static/` は使用しない。 + +## Coding conventions +- Python: PEP 8。Black を想定。型ヒントを優先。 +- Django: CSRF を無効化しない。`@csrf_exempt` は避け、AJAX はトークン送付。 +- モデル: `get_absolute_url` は `urls.py` の name と一致させる。 +- スラグ生成はモデルで一元化。ビューの重複ロジックは追加しない。 +- ログ: 資格情報や秘密は出力しない。`print` ではなくロギングを使用。 +- 静的配信: 本番は Nginx に委譲。Django の `static()` 追加は DEBUG 条件下のみ。 + +## Security & secrets +- 秘密情報は環境変数で管理。ソースに直書きしない。 +- `.env` はコミットしない。共有は `.env.example` にキーのみ追加。 +- `SECRET_KEY` 未設定で本番起動させない設計を前提。 +- Markdown 生成はサニタイズ必須(許可タグ/属性限定)。 + +## Expectations in CI / PR +- CI で pytest が通ること。必要に応じて `collectstatic` も検証。 +- 1 PR = 1 目的。機能追加と大規模リファクタを混在させない。 +- 依存追加時は `requirements.txt` を更新し、採用理由と影響範囲を記載。 +- 仕様/セキュリティに関わる変更は `_note/requirements.md` / `_note/todo.md` を更新。 + +## Preferences for Copilot +- 既存スタイル/構成に沿い、小さく安全に提案・変更する。 +- 変更はテスト追加/更新を伴う提案とする(pytest/Playwright)。 +- インフラ変更は Docker Compose/Nginx の現行設計に合わせる。 +- 既知の重複ツリー(`app/techblog_cms`)には新規ファイルを作らない。 +- ファイル参照はパスと行番号を明示する。 + +--- + +### よくある作業例(補助) +- 「API/ビュー追加」: URL name 設計 → ビュー実装 → テンプレ/Serializer → 単体テスト → E2E 影響確認。 +- 「Markdown処理強化」: サニタイズ導入 → 危険タグ除去テスト → 本番/プレビューの同一パイプライン化。 +- 「CSRF関連不具合」: フロントのトークン付与 → ミドルウェア順番確認 → 403 ハンドリング → テスト整備。 + +参照: `AGENTS.md`(AIアシスタント向けガイド) + diff --git a/.gitignore b/.gitignore index 5a35e8e..26cd526 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,7 @@ _note/ # backup files *.bak -*.tmp \ No newline at end of file +*.tmp + +# Docker override files +docker-compose.override.yml diff --git a/AGENTS.md b/AGENTS.md index 016b300..0b16fa8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,108 +1,112 @@ -# AGENTS.md - AIアシスタント向けガイド - -## 目的 -このドキュメントは、GitHub CopilotやClaudeなどのAIアシスタントがこのレポジトリ(techblog_cms)を理解し、効果的に支援するためのガイドラインを提供します。レポジトリの構造、技術スタック、開発プロセスを明確にし、AIが適切なコード生成やアドバイスを行えるようにします。 - -## レポジトリ概要 -techblog_cmsは、Djangoベースの技術ブログコンテンツ管理システムです。Docker Composeを使用したコンテナ化環境で、Nginxによるリバースプロキシ、PostgreSQLデータベース、Redisキャッシュ、Let's EncryptによるSSL証明書の自動管理を統合しています。プロダクションレディな構成を目指し、セキュリティとスケーラビリティを重視しています。 - -## 技術スタック -- **バックエンド**: Django 4.2, Python 3.11 -- **データベース**: PostgreSQL -- **キャッシュ**: Redis -- **Webサーバー**: Nginx (リバースプロキシ + 静的ファイル配信) -- **コンテナ化**: Docker, Docker Compose -- **SSL証明書**: Let's Encrypt (Certbot) -- **テスト**: pytest, Django Test Framework +# AGENTS.md - Guide for AI Assistants + +## Purpose +This document provides guidelines for AI assistants like GitHub Copilot and Claude to understand this repository (techblog_cms) and assist effectively. It clarifies the repository structure, tech stack, and development process to enable AI to generate appropriate code and provide sound advice. + +## Repository Overview +techblog_cms is a Django-based technical blog content management system. It operates in a containerized environment using Docker Compose, integrating Nginx reverse proxy, PostgreSQL database, Redis cache, and automated SSL certificate management via Let's Encrypt. The configuration prioritizes security and scalability, aiming for production readiness. + +## Technology Stack +- **Backend**: Django 4.2, Python 3.11 +- **Database**: PostgreSQL +- **Cache**: Redis +- **Web Server**: Nginx (Reverse Proxy + Static File Delivery) +- **Containerization**: Docker, Docker Compose +- **SSL Certificates**: Let's Encrypt (Certbot) +- **Testing**: pytest, Django Test Framework - **CI/CD**: GitHub Actions -- **セキュリティ**: HTTPS強制, セキュリティヘッダー, 環境変数管理 -- **フロントエンド**: HTML/CSS (Tailwind CSS), JavaScript (最小限) +- **Security**: HTTPS enforcement, security headers, environment variable management +- **Frontend**: HTML/CSS (Tailwind CSS), JavaScript (minimal) -## プロジェクト構造 +## Project Structure ``` techblog_cms/ -├── app/ # Djangoアプリケーション -│ ├── techblog_cms/ # メインDjangoアプリ +├── app/ # Django application +│ ├── techblog_cms/ # Main Django app │ │ ├── __init__.py -│ │ ├── settings.py # Django設定(環境変数使用) -│ │ ├── urls.py # URLマッピング -│ │ ├── views.py # ビュー関数 -│ │ ├── wsgi.py # WSGIエントリーポイント -│ │ └── templates/ # HTMLテンプレート -│ └── requirements.txt # Python依存関係 -├── nginx/ # Nginx設定 +│ │ ├── settings.py # Django settings (using environment variables) +│ │ ├── urls.py # URL mapping +│ │ ├── views.py # View functions +│ │ ├── wsgi.py # WSGI entry point +│ │ └── templates/ # HTML templates +│ └── requirements.txt # Python dependencies +├── nginx/ # Nginx configuration │ ├── conf.d/ -│ │ └── default.conf # Nginx設定ファイル -│ └── Dockerfile # Nginxコンテナ定義 -├── scripts/ # ユーティリティスクリプト -│ ├── init-letsencrypt.sh # SSL証明書初期化 -│ └── renew-cert.sh # SSL証明書更新 -├── static/ # 静的ファイル -├── tests/ # テストファイル -├── docker-compose.yml # コンテナオーケストレーション -├── Dockerfile.* # 各種Dockerfile -├── requirements.txt # プロジェクト全体の依存関係 -└── pytest.ini # pytest設定 +│ │ └── default.conf # Nginx configuration file +│ └── Dockerfile # Nginx container definition +├── scripts/ # Utility scripts +│ ├── init-letsencrypt.sh # Initialize SSL certificate +│ └── renew-cert.sh # Renew SSL certificate +├── static/ # Static files +├── tests/ # Test files +├── docker-compose.yml # Container orchestration +├── Dockerfile.* # Various Dockerfiles +├── requirements.txt # Project-wide dependencies +└── pytest.ini # pytest configuration ``` -## 開発ガイドライン - -### コーディング標準 -- **Python**: PEP 8準拠、Blackフォーマッター使用 -- **Django**: Djangoベストプラクティスに従う -- **Docker**: マルチステージビルド、セキュリティスキャン(Trivy) -- **セキュリティ**: 環境変数で機密情報を管理、HTTPS強制 - -### 環境変数 -重要な設定は環境変数で管理: -- `DEBUG`: デバッグモード(本番ではFalse) -- `SECRET_KEY`: Djangoシークレットキー -- `DATABASE_URL`: PostgreSQL接続文字列 -- `REDIS_URL`: Redis接続文字列 -- `ALLOWED_HOSTS`: 許可ホストリスト - -### テスト -- pytestを使用したユニットテストと統合テスト -- CI/CDで自動テスト実行 -- カバレッジレポート生成 - -## AIアシスタントへの指示 - -### コード生成時の考慮事項 -1. **セキュリティ優先**: 機密情報は環境変数を使用。ハードコーディング禁止。 -2. **コンテナ化対応**: Dockerベストプラクティスに従い、軽量イメージを作成。 -3. **Djangoベストプラクティス**: ビュー関数、モデル、テンプレートの適切な分離。 -4. **エラーハンドリング**: 適切な例外処理とログ出力。 -5. **パフォーマンス**: データベースクエリの最適化、キャッシュの活用。 - -### 支援時のガイドライン -1. **新規機能追加**: 既存の構造に準拠。必要に応じてマイグレーション作成。 -2. **バグ修正**: テストケースを追加し、リグレッションを防ぐ。 -3. **ドキュメント更新**: コード変更時はREADME.mdやこのAGENTS.mdを更新。 -4. **依存関係**: 新しいパッケージ追加時はrequirements.txtを更新。 -5. **Docker設定**: 変更時はdocker-compose.ymlと関連Dockerfileを確認。 - -### 禁止事項 -- ハードコーディングされたパスワードやAPIキー -- 非効率なデータベースクエリ -- セキュリティホール(SQLインジェクション、XSSなど) -- 不要な依存関係の追加 - -### 推奨ツール使用 -- **コード編集**: replace_string_in_file または insert_edit_into_file -- **ファイル作成**: create_file -- **ターミナル実行**: run_in_terminal(Dockerコマンドなど) -- **テスト実行**: runTests -- **ファイル検索**: grep_search または semantic_search - -## 貢献ガイド -1. developブランチからフィーチャーブランチを作成 -2. 変更を実装し、テストを追加 -3. プルリクエストを作成し、レビューを依頼 -4. マージ後、必要に応じてドキュメント更新 - -## 連絡先 -質問や改善提案は、GitHub IssuesまたはPull Requestsをご利用ください。 +## Development Guidelines + +### Coding Standards +- **Python**: PEP 8 compliant, using Black formatter +- **Django**: Follow Django best practices +- **Docker**: Multi-stage builds, security scanning (Trivy) +- **Security**: Manage sensitive info via environment variables, enforce HTTPS + +### Environment Variables +Critical settings managed via environment variables: +- `DEBUG`: Debug mode (False in production) +- `SECRET_KEY`: Django secret key +- `DATABASE_URL`: PostgreSQL connection string +- `REDIS_URL`: Redis connection string +- `ALLOWED_HOSTS`: List of allowed hosts + + +### Testing +- Unit and integration testing using pytest +- Automated test execution via CI/CD +- Coverage report generation + + +## Instructions for AI Assistant + + +### Considerations for Code Generation +1. **Security First**: Use environment variables for sensitive data. Do not hardcode. +2. **Containerization Ready**: Follow Docker best practices to create lightweight images. +3. **Django Best Practices**: Properly separate view functions, models, and templates. +4. **Error Handling**: Implement proper exception handling and log output. +5. **Performance**: Optimize database queries and utilize caching. + +### Guidelines for Support +1. **Adding New Features**: Adhere to existing structure. Create migrations as needed. +2. **Bug Fixes**: Add test cases to prevent regressions. +3. **Document Updates**: Update README.md and this AGENTS.md when modifying code. +4. **Dependencies**: Update requirements.txt when adding new packages. +5. **Docker Configuration**: Verify docker-compose.yml and related Dockerfiles when making changes. + +### Prohibited Actions +- Hard-coded passwords or API keys +- Inefficient database queries +- Security vulnerabilities (SQL injection, XSS, etc.) +- Adding unnecessary dependencies + +### Recommended Tool Usage +- **Code Editing**: `replace_string_in_file` or `insert_edit_into_file` +- **File Creation**: `create_file` +- **Terminal Execution**: run_in_terminal (e.g., Docker commands) +- **Test Execution**: runTests +- **File Search**: grep_search or semantic_search + +## Contribution Guide +1. Create a feature branch from the develop branch +2. If the current branch is main, create the feature branch from main. +2. Implement changes and add tests. +3. Create a pull request and request a review. +4. After merging, update documentation as needed. + +## Contact +Please use GitHub Issues or Pull Requests for questions or improvement suggestions. --- -最終更新: 2025年8月31日 +Last updated: August 31, 2025 diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..f22cfc9 --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,34 @@ +# Docker Compose Override for Development +# Copy this file to docker-compose.override.yml for local development + +version: '3' + +services: + django: + # Mount source code for hot reloading + volumes: + - .:/app + - logs:/app/logs + # Enable debug mode for development + environment: + DEBUG: "True" + ALLOWED_HOSTS: "localhost,127.0.0.1" + # Use development server instead of gunicorn + command: python manage.py runserver 0.0.0.0:8000 + + db: + # Expose PostgreSQL port for local development tools + ports: + - "5432:5432" + + redis: + # Expose Redis port for local development tools + ports: + - "6379:6379" + + nginx: + # Disable HTTPS for local development + ports: + - "80:80" + # Comment out HTTPS port for local dev + # - "443:443" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0fbc639..0fb876e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,11 +51,12 @@ services: expose: - 8000 environment: - SECRET_KEY: ${SECRET_KEY} + SECRET_KEY: ${SECRET_KEY} # Loaded from .env file DEBUG: ${DEBUG:-False} - ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,blog.iohub.link} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} # Prefer passing through DATABASE_URL from .env to avoid duplication DATABASE_URL: ${DATABASE_URL} + # Use a single REDIS_URL derived from REDIS_PASSWORD for consistency REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 DJANGO_SETTINGS_MODULE: techblog_cms.settings PYTHONPATH: /app @@ -75,6 +76,12 @@ services: memory: 512M # Security: Runs the application as a non-root user. # Environment variables for sensitive settings. + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health/')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s # Database - PostgreSQL db: @@ -105,9 +112,9 @@ services: # Caching - Redis redis: image: redis:7-alpine - command: redis-server --requirepass ${REDIS_PASSWORD} - environment: - REDIS_PASSWORD: ${REDIS_PASSWORD} + command: redis-server --requirepass ${REDIS_PASSWORD:-your_redis_password} # Set a password + ports: + - "6379:6379" networks: - techblog_network restart: always diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..29e45ca --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,324 @@ +# Tech Blog CMS Configuration Guide + +This guide covers all configuration aspects of the Tech Blog CMS application. + +## Table of Contents +- [Environment Variables](#environment-variables) +- [Django Settings](#django-settings) +- [Docker Configuration](#docker-configuration) +- [Nginx Configuration](#nginx-configuration) +- [Database Configuration](#database-configuration) +- [Redis Configuration](#redis-configuration) +- [SSL/TLS Configuration](#ssltls-configuration) +- [Development Setup](#development-setup) +- [Production Deployment](#production-deployment) + +## Environment Variables + +Create a `.env` file in the project root (copy from `.env.example`): + +```bash +cp .env.example .env +``` + +### Required Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `SECRET_KEY` | Django secret key (generate a secure one) | `your-50-char-secret-key` | +| `DEBUG` | Debug mode (False for production) | `False` | +| `ALLOWED_HOSTS` | Comma-separated list of allowed hosts | `.localhost,127.0.0.1,yourdomain.com` | +| `DATABASE_URL` | PostgreSQL connection string | `postgres://user:pass@host:5432/dbname` | +| `REDIS_URL` | Redis connection string | `redis://redis:6379/1` | + +### Database Credentials + +| Variable | Description | Default | +|----------|-------------|---------| +| `POSTGRES_USER` | PostgreSQL username | `techblog` | +| `POSTGRES_PASSWORD` | PostgreSQL password | `techblogpass` | +| `POSTGRES_DB` | PostgreSQL database name | `techblogdb` | + +### Optional OAuth Settings + +| Variable | Description | +|----------|-------------| +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | +| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | + +### Security Settings + +| Variable | Description | Production Value | +|----------|-------------|------------------| +| `CSRF_TRUSTED_ORIGINS` | Trusted origins for CSRF | `https://yourdomain.com` | +| `SECURE_SSL_REDIRECT` | Force HTTPS redirect | `True` | +| `SESSION_COOKIE_SECURE` | Secure session cookies | `True` | +| `CSRF_COOKIE_SECURE` | Secure CSRF cookies | `True` | + +## Django Settings + +The main Django settings file is located at `techblog_cms/settings.py`. + +### Key Configuration Areas + +1. **Secret Key Management** + ```python + SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-default-key') + ``` + +2. **Debug Mode** + ```python + DEBUG = config('DEBUG', default=False, cast=bool) + ``` + +3. **Database Configuration** + ```python + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('POSTGRES_DB', 'techblogdb'), + 'USER': os.environ.get('POSTGRES_USER', 'techblog'), + 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'techblogpass'), + 'HOST': 'db', + 'PORT': '5432', + } + } + ``` + +4. **Static Files** + ```python + STATIC_URL = 'static/' + STATIC_ROOT = os.path.join(BASE_DIR, 'static') + ``` + +## Docker Configuration + +### Docker Compose Services + +The application uses Docker Compose with the following services: + +1. **nginx** - Load balancer and web server +2. **django** - Web application server +3. **db** - PostgreSQL database +4. **redis** - Caching and session storage +5. **static** - Static file server +6. **certbot** - SSL certificate management + +### Development Override + +For local development, create a `docker-compose.override.yml`: + +```bash +cp docker-compose.override.yml.example docker-compose.override.yml +``` + +This file enables: +- Hot reloading +- Debug mode +- Exposed database ports +- Simplified networking + +## Nginx Configuration + +### Production Configuration +- HTTPS with TLS 1.2/1.3 +- HTTP/2 support +- Security headers (HSTS, X-Frame-Options, etc.) +- OCSP stapling +- Gzip compression + +### Development Configuration +- HTTP only on port 80 +- Simplified proxy settings +- No SSL requirements + +### Key Locations +- Static files: `/static/` +- Media files: `/media/` +- ACME challenges: `/.well-known/acme-challenge/` + +## Database Configuration + +### PostgreSQL Settings +- Version: 16 (Alpine) +- Default port: 5432 +- Data persistence: Docker volume `db_data` + +### Connection Pooling +Configure in Django settings: +```python +DATABASES['default']['CONN_MAX_AGE'] = 60 +``` + +### Backup Strategy +Regular backups recommended using: +```bash +docker-compose exec db pg_dump -U $POSTGRES_USER $POSTGRES_DB > backup.sql +``` + +## Redis Configuration + +### Usage +- Session storage +- Cache backend +- Celery broker (if implemented) + +### Security +- Password protection enabled +- Network isolation via Docker networks +- Version: Redis 7 (Alpine) + +### Configuration in Django +```python +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': os.environ.get('REDIS_URL', 'redis://redis:6379/1'), + } +} +``` + +## SSL/TLS Configuration + +### Let's Encrypt Integration +1. Domain configuration in `.env`: + ``` + DOMAIN=yourdomain.com + ``` + +2. Initialize certificates: + ```bash + ./scripts/init-letsencrypt.sh + ``` + +3. Automatic renewal via Certbot container + +### SSL Settings +- Protocols: TLS 1.2, TLS 1.3 +- Strong cipher suites +- OCSP stapling enabled +- HSTS with preload + +## Development Setup + +1. **Create environment file** + ```bash + cp .env.example .env + # Edit .env with your settings + ``` + +2. **Create override file** + ```bash + cp docker-compose.override.yml.example docker-compose.override.yml + ``` + +3. **Start services** + ```bash + docker-compose up -d + ``` + +4. **Run migrations** + ```bash + docker-compose exec django python manage.py migrate + ``` + +5. **Create superuser** + ```bash + docker-compose exec django python manage.py createsuperuser + ``` + +## Production Deployment + +### Pre-deployment Checklist + +- [ ] Set `DEBUG=False` in `.env` +- [ ] Generate strong `SECRET_KEY` +- [ ] Configure `ALLOWED_HOSTS` with your domain +- [ ] Set secure database passwords +- [ ] Configure Redis password +- [ ] Enable all security settings +- [ ] Set up SSL certificates +- [ ] Configure backups +- [ ] Set up monitoring + +### Deployment Steps + +1. **Prepare environment** + ```bash + # Copy and configure .env + cp .env.example .env + # Edit with production values + ``` + +2. **Build and start services** + ```bash + docker-compose build + docker-compose up -d + ``` + +3. **Initialize SSL** + ```bash + ./scripts/init-letsencrypt.sh + ``` + +4. **Run database migrations** + ```bash + docker-compose exec django python manage.py migrate + ``` + +5. **Collect static files** + ```bash + docker-compose exec django python manage.py collectstatic --noinput + ``` + +### Health Checks + +Monitor service health: +```bash +docker-compose ps +docker-compose logs -f [service_name] +``` + +### Scaling + +To scale Django workers: +```bash +docker-compose up -d --scale django=3 +``` + +## Troubleshooting + +### Common Issues + +1. **Database connection errors** + - Check `DATABASE_URL` format + - Verify PostgreSQL container is running + - Check network connectivity + +2. **Static files not loading** + - Run `collectstatic` command + - Check nginx volume mounts + - Verify `STATIC_ROOT` setting + +3. **SSL certificate issues** + - Ensure domain DNS is configured + - Check Certbot logs + - Verify nginx SSL configuration + +### Debug Commands + +```bash +# Check Django logs +docker-compose logs django + +# Access Django shell +docker-compose exec django python manage.py shell + +# Check nginx configuration +docker-compose exec nginx nginx -t + +# Database console +docker-compose exec db psql -U $POSTGRES_USER $POSTGRES_DB +``` \ No newline at end of file diff --git a/docs/QUICK_CONFIG_REFERENCE.md b/docs/QUICK_CONFIG_REFERENCE.md new file mode 100644 index 0000000..ce4d45f --- /dev/null +++ b/docs/QUICK_CONFIG_REFERENCE.md @@ -0,0 +1,128 @@ +# Tech Blog CMS - Quick Configuration Reference + +## Essential Files + +1. **`.env`** - Environment variables (create from `.env.example`) +2. **`docker-compose.yml`** - Service orchestration +3. **`techblog_cms/settings.py`** - Django settings +4. **`nginx/conf.d/default.conf`** - Web server configuration + +## Quick Start + +```bash +# 1. Copy and configure environment +cp .env.example .env +# Edit .env with your settings + +# 2. For development +cp docker-compose.override.yml.example docker-compose.override.yml + +# 3. Start services +docker-compose up -d + +# 4. Initialize database +docker-compose exec django python manage.py migrate + +# 5. Create admin user +docker-compose exec django python manage.py createsuperuser +``` + +## Key Environment Variables + +```bash +# Security +SECRET_KEY= # python -c "import secrets; print(secrets.token_urlsafe(50))" +DEBUG=False # Always False in production + +# Database +POSTGRES_USER=techblog_prod_user +POSTGRES_PASSWORD= +POSTGRES_DB=techblog_prod_db + +# Redis +REDIS_PASSWORD= + +# Domain +DOMAIN=yourdomain.com +ALLOWED_HOSTS=.yourdomain.com,yourdomain.com +``` + +## Service Endpoints + +- **Application**: http://localhost (dev) / https://yourdomain.com (prod) +- **Admin Panel**: /admin/ +- **Health Check**: /health/ +- **Readiness Check**: /ready/ +- **Static Files**: /static/ +- **Media Files**: /media/ + +## Useful Commands + +```bash +# Check production readiness +./scripts/production_checklist.sh + +# Database backup +docker-compose exec django python manage.py backup_db + +# Collect static files +docker-compose exec django python manage.py collectstatic --noinput + +# View logs +docker-compose logs -f django +docker-compose logs -f nginx + +# Shell access +docker-compose exec django python manage.py shell +docker-compose exec db psql -U $POSTGRES_USER $POSTGRES_DB +``` + +## Directory Structure + +``` +techblog_cms/ +├── .env # Environment variables (git ignored) +├── docker-compose.yml # Service definitions +├── docker-compose.override.yml # Development overrides (git ignored) +├── techblog_cms/ # Django application +│ ├── settings.py # Main settings +│ ├── settings_production.py # Production overrides +│ └── management/ # Custom commands +├── nginx/ # Web server config +│ ├── conf.d/ # Site configurations +│ └── ssl/ # SSL certificates +├── logs/ # Application logs +├── static/ # Static assets +├── media/ # User uploads +└── backups/ # Database backups +``` + +## Security Checklist + +- [ ] Strong SECRET_KEY generated +- [ ] DEBUG=False in production +- [ ] Database passwords changed from defaults +- [ ] Redis password configured +- [ ] SSL certificates installed +- [ ] ALLOWED_HOSTS properly configured +- [ ] Security headers enabled +- [ ] Regular backups scheduled + +## Troubleshooting + +1. **Can't connect to database** + - Check DATABASE_URL format + - Verify PostgreSQL is running: `docker-compose ps db` + +2. **Static files not loading** + - Run: `docker-compose exec django python manage.py collectstatic` + - Check nginx volumes in docker-compose.yml + +3. **SSL issues** + - Ensure domain DNS points to server + - Run: `./scripts/init-letsencrypt.sh` + - Check: `docker-compose logs certbot` + +4. **Application errors** + - Check logs: `docker-compose logs django` + - Verify migrations: `docker-compose exec django python manage.py showmigrations` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 957bbbc..96b047f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,52 @@ -# Add other dependencies as needed -redis>=5.0,<6.0 -psycopg2-binary>=2.9,<3.0 -gunicorn>=21.2,<22.0 +# Core dependencies Django==4.2.10 python-decouple>=3.8,<4.0 Pillow>=10.0,<11.0 -playwright>=1.40,<2.0 -pytest>=7.0,<8.0 -pytest-django>=4.5,<5.0 -Markdown>=3.4,<4.0 Pygments>=2.15,<3.0 + +# Database +psycopg2-binary>=2.9,<3.0 + +# Caching and sessions +redis>=5.0,<6.0 +django-redis>=5.4,<6.0 + +# Web server +gunicorn>=21.2,<22.0 + +# HTTP client (for healthcheck) +requests>=2.31,<3.0 + +# Production optimizations +whitenoise>=6.5,<7.0 # Static file serving +django-compressor>=4.4,<5.0 # CSS/JS compression + +# Security +django-cors-headers>=4.3,<5.0 +django-csp>=3.7,<4.0 # Content Security Policy + +# Monitoring and debugging (production) +sentry-sdk>=1.39,<2.0 +django-extensions>=3.2,<4.0 + +# Testing +pytest>=7.4,<8.0 +pytest-django>=4.7,<5.0 +pytest-cov>=4.1,<5.0 +playwright>=1.40,<2.0 + +# Code quality +black>=23.12,<24.0 +flake8>=6.1,<7.0 +isort>=5.13,<6.0 + +# Documentation +markdown>=3.5,<4.0 +linkify-it-py>=2.0,<3.0 + +# Optional: Image processing +# Pillow>=10.1,<11.0 + +# Optional: API framework +# djangorestframework>=3.14,<4.0 +# django-filter>=23.5,<24.0 diff --git a/scripts/production_checklist.sh b/scripts/production_checklist.sh new file mode 100755 index 0000000..50fccb7 --- /dev/null +++ b/scripts/production_checklist.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Production Deployment Checklist Script +# This script helps verify that the system is ready for production deployment + +echo "=========================================" +echo "Tech Blog CMS Production Checklist" +echo "=========================================" +echo "" + +# Color codes +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Track overall status +READY=true + +# Function to check a condition +check() { + local description=$1 + local command=$2 + + echo -n "Checking: $description... " + + if eval $command > /dev/null 2>&1; then + echo -e "${GREEN}✓ PASS${NC}" + else + echo -e "${RED}✗ FAIL${NC}" + READY=false + fi +} + +# Function to check environment variable +check_env() { + local var_name=$1 + local description=$2 + + echo -n "Checking: $description... " + + if [ -n "${!var_name}" ]; then + echo -e "${GREEN}✓ SET${NC}" + else + echo -e "${RED}✗ NOT SET${NC}" + READY=false + fi +} + +echo "1. Environment Variables" +echo "------------------------" +check_env "SECRET_KEY" "Django SECRET_KEY" +check_env "POSTGRES_PASSWORD" "PostgreSQL password" +check_env "REDIS_PASSWORD" "Redis password" +check_env "DOMAIN" "Domain name" + +echo "" +echo "2. Configuration Files" +echo "----------------------" +check "Environment file exists" "[ -f .env ]" +check "Production settings exist" "[ -f techblog_cms/settings_production.py ]" +check ".env is in .gitignore" "grep -q '^\.env$' .gitignore" + +echo "" +echo "3. Security Settings" +echo "-------------------" +check "DEBUG is False" "grep -q 'DEBUG=False' .env" +check "SECRET_KEY is not default" "! grep -q 'django-insecure' .env" +check "SECURE_SSL_REDIRECT is True" "grep -q 'SECURE_SSL_REDIRECT=True' .env" + +echo "" +echo "4. Docker Services" +echo "-----------------" +check "Docker is installed" "command -v docker" +check "Docker Compose is installed" "command -v docker-compose" +check "Docker daemon is running" "docker info" + +echo "" +echo "5. SSL/TLS Setup" +echo "----------------" +check "SSL init script exists" "[ -f scripts/init-letsencrypt.sh ]" +check "SSL init script is executable" "[ -x scripts/init-letsencrypt.sh ]" +check "Nginx SSL directory exists" "[ -d nginx/ssl ]" + +echo "" +echo "6. Directory Structure" +echo "---------------------" +check "Logs directory exists" "[ -d logs ]" +check "Static directory exists" "[ -d static ]" +check "Media directory exists" "[ -d media ] || mkdir -p media" +check "Backups directory exists" "[ -d backups ] || mkdir -p backups" + +echo "" +echo "=========================================" +if [ "$READY" = true ]; then + echo -e "${GREEN}✓ System is ready for production deployment!${NC}" + echo "" + echo "Next steps:" + echo "1. Run: docker-compose build" + echo "2. Run: ./scripts/init-letsencrypt.sh (for SSL setup)" + echo "3. Run: docker-compose up -d" + echo "4. Run: docker-compose exec django python manage.py migrate" + echo "5. Run: docker-compose exec django python manage.py collectstatic --noinput" + echo "6. Run: docker-compose exec django python manage.py createsuperuser" +else + echo -e "${RED}✗ System is NOT ready for production deployment${NC}" + echo "" + echo "Please fix the issues above before deploying to production." +fi +echo "=========================================" + +exit $([ "$READY" = true ] && echo 0 || echo 1) \ No newline at end of file diff --git a/techblog_cms/health.py b/techblog_cms/health.py new file mode 100644 index 0000000..f959c27 --- /dev/null +++ b/techblog_cms/health.py @@ -0,0 +1,58 @@ +""" +Health check views for monitoring +""" +import logging +from django.http import JsonResponse +from django.db import connection +from django.core.cache import cache +from django.views import View + +logger = logging.getLogger(__name__) + + +class HealthCheckView(View): + """Basic health check endpoint""" + + def get(self, request): + return JsonResponse({ + 'status': 'healthy', + 'service': 'techblog_cms' + }) + + +class ReadinessCheckView(View): + """Readiness check - verifies all dependencies are accessible""" + + def get(self, request): + checks = { + 'database': self._check_database(), + 'cache': self._check_cache(), + } + + all_healthy = all(checks.values()) + status_code = 200 if all_healthy else 503 + + return JsonResponse({ + 'status': 'ready' if all_healthy else 'not_ready', + 'checks': checks + }, status=status_code) + + def _check_database(self): + """Check database connectivity""" + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + return True + except Exception as e: + logger.error(f"Database health check failed: {e}") + return False + + def _check_cache(self): + """Check cache connectivity""" + try: + cache.set('health_check', 'ok', 10) + value = cache.get('health_check') + return value == 'ok' + except Exception as e: + logger.error(f"Cache health check failed: {e}") + return False \ No newline at end of file diff --git a/techblog_cms/management/__init__.py b/techblog_cms/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/techblog_cms/management/commands/__init__.py b/techblog_cms/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/techblog_cms/management/commands/backup_db.py b/techblog_cms/management/commands/backup_db.py new file mode 100644 index 0000000..2a3a67b --- /dev/null +++ b/techblog_cms/management/commands/backup_db.py @@ -0,0 +1,90 @@ +""" +Database backup management command +""" +import os +import subprocess +from datetime import datetime +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db import connection + + +class Command(BaseCommand): + help = 'Create a backup of the database' + + def add_arguments(self, parser): + parser.add_argument( + '--output', + type=str, + default=None, + help='Output file path for the backup' + ) + parser.add_argument( + '--compress', + action='store_true', + help='Compress the backup with gzip' + ) + + def handle(self, *args, **options): + db_settings = connection.settings_dict + + # Generate backup filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_dir = os.path.join(settings.BASE_DIR, 'backups') + os.makedirs(backup_dir, exist_ok=True) + + if options['output']: + backup_file = options['output'] + else: + backup_file = os.path.join( + backup_dir, + f"backup_{db_settings['NAME']}_{timestamp}.sql" + ) + + # Build pg_dump command + env = os.environ.copy() + env['PGPASSWORD'] = db_settings['PASSWORD'] + + cmd = [ + 'pg_dump', + '-h', db_settings['HOST'], + '-p', str(db_settings['PORT']), + '-U', db_settings['USER'], + '-d', db_settings['NAME'], + '--no-password', + '--verbose', + ] + + try: + self.stdout.write(f"Creating backup: {backup_file}") + + if options['compress']: + backup_file += '.gz' + with open(backup_file, 'wb') as f: + p1 = subprocess.Popen(cmd, stdout=subprocess.PIPE, env=env) + p2 = subprocess.Popen(['gzip'], stdin=p1.stdout, stdout=f) + p1.stdout.close() + p2.communicate() + else: + with open(backup_file, 'w') as f: + subprocess.run(cmd, stdout=f, env=env, check=True) + + self.stdout.write( + self.style.SUCCESS(f"Successfully created backup: {backup_file}") + ) + + # Get file size + size = os.path.getsize(backup_file) + size_mb = size / (1024 * 1024) + self.stdout.write(f"Backup size: {size_mb:.2f} MB") + + except subprocess.CalledProcessError as e: + self.stdout.write( + self.style.ERROR(f"Backup failed: {e}") + ) + raise + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Unexpected error: {e}") + ) + raise \ No newline at end of file diff --git a/techblog_cms/models.py b/techblog_cms/models.py index 04c9038..06602a6 100644 --- a/techblog_cms/models.py +++ b/techblog_cms/models.py @@ -1,3 +1,4 @@ +import uuid from django.db import models from django.utils.text import slugify from django.urls import reverse @@ -51,9 +52,33 @@ class Article(models.Model): tags = models.ManyToManyField(Tag, blank=True) image = models.ImageField(upload_to='articles/', blank=True, null=True) + def _generate_unique_slug(self): + slug_field = self._meta.get_field('slug') + max_length = slug_field.max_length or 50 + suffix_length = 8 + base_slug = slugify(self.title) or 'article' + + if max_length > suffix_length + 1: + base_max_length = max_length - (suffix_length + 1) + base_slug = base_slug[:base_max_length].rstrip('-') + if not base_slug: + base_slug = 'article' + base_slug = base_slug[:base_max_length] + else: + base_slug = '' + + while True: + if base_slug: + hash_fragment = uuid.uuid4().hex[:suffix_length] + candidate = f"{base_slug}-{hash_fragment}" + else: + candidate = uuid.uuid4().hex[:max_length] + if not Article.objects.filter(slug=candidate).exclude(pk=self.pk).exists(): + return candidate + def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(self.title) + self.slug = self._generate_unique_slug() if not self.excerpt and self.content: self.excerpt = self.content[:200] super().save(*args, **kwargs) @@ -62,4 +87,4 @@ def __str__(self): return self.title def get_absolute_url(self): - return reverse('article', kwargs={'slug': self.slug}) + return reverse('article_detail', kwargs={'slug': self.slug}) diff --git a/techblog_cms/settings.py b/techblog_cms/settings.py index 24e4df2..d9b8416 100644 --- a/techblog_cms/settings.py +++ b/techblog_cms/settings.py @@ -67,15 +67,11 @@ print(f"IS_TESTING: {IS_TESTING}") if IS_TESTING: - # Testing uses explicit env vars to keep CI simple + # Testing uses SQLite for simplicity DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.environ.get('POSTGRES_DB', 'techblogdb'), - 'USER': os.environ.get('POSTGRES_USER', 'techblog'), - 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'techblogpass'), - 'HOST': os.environ.get('POSTGRES_HOST', 'db'), - 'PORT': os.environ.get('POSTGRES_PORT', '5432'), + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', } } # Disable CSRF for testing @@ -154,3 +150,150 @@ # Default primary key field type DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 8, + } + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Security Settings for Production +if not DEBUG: + SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=True, cast=bool) + SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=True, cast=bool) + CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=True, cast=bool) + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + X_FRAME_OPTIONS = 'DENY' + SECURE_HSTS_SECONDS = 31536000 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# CSRF Settings +CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', default='', cast=Csv()) + +# Cache Configuration +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': config('REDIS_URL', default='redis://redis:6379/1'), + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + 'KEY_PREFIX': 'techblog', + 'TIMEOUT': 300, + } +} + +# Session Configuration +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' +SESSION_CACHE_ALIAS = 'default' +SESSION_COOKIE_AGE = 86400 # 24 hours +SESSION_COOKIE_NAME = 'techblog_sessionid' + +# Logging Configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + }, + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + }, + 'file': { + 'level': 'ERROR', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(BASE_DIR, 'logs', 'django.log'), + 'maxBytes': 1024 * 1024 * 15, # 15MB + 'backupCount': 10, + 'formatter': 'verbose', + }, + 'mail_admins': { + 'level': 'ERROR', + 'class': 'django.utils.log.AdminEmailHandler', + 'filters': ['require_debug_false'], + } + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': False, + }, + 'django.request': { + 'handlers': ['file', 'mail_admins'], + 'level': 'ERROR', + 'propagate': False, + }, + 'techblog_cms': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG' if DEBUG else 'INFO', + 'propagate': False, + }, + }, +} + +# Media files configuration +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# Email configuration (for production) +if not DEBUG: + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com') + EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) + EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool) + EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') + EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') + DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@techblog.com') + ADMINS = [('Admin', config('ADMIN_EMAIL', default='admin@techblog.com'))] + +# Testing configuration +if 'test' in sys.argv: + DATABASES['default'] = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', + ] + diff --git a/techblog_cms/settings_production.py b/techblog_cms/settings_production.py new file mode 100644 index 0000000..9dca2d7 --- /dev/null +++ b/techblog_cms/settings_production.py @@ -0,0 +1,59 @@ +""" +Production settings for Tech Blog CMS +""" +from .settings import * + +# Override settings for production +DEBUG = False + +# Security Settings - All should be True in production +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = 'DENY' +SECURE_HSTS_SECONDS = 31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# Content Security Policy +CSP_DEFAULT_SRC = ("'self'",) +CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", "https://fonts.googleapis.com") +CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'", "'unsafe-eval'") +CSP_FONT_SRC = ("'self'", "https://fonts.gstatic.com") +CSP_IMG_SRC = ("'self'", "data:", "https:") + +# Additional middleware for production +MIDDLEWARE.insert(0, 'django.middleware.security.SecurityMiddleware') + +# Force cookies to be httponly +SESSION_COOKIE_HTTPONLY = True +CSRF_COOKIE_HTTPONLY = True + +# Database connection pooling +DATABASES['default']['CONN_MAX_AGE'] = 60 + +# Disable debug toolbar in production +INTERNAL_IPS = [] + +# Use whitenoise for static files in production (optional) +# MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware') +# STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Production logging - only log warnings and above +LOGGING['root']['level'] = 'WARNING' +LOGGING['loggers']['django']['level'] = 'WARNING' +LOGGING['loggers']['techblog_cms']['level'] = 'INFO' + +# Sentry integration (optional) +# import sentry_sdk +# from sentry_sdk.integrations.django import DjangoIntegration +# +# sentry_sdk.init( +# dsn=config('SENTRY_DSN', default=''), +# integrations=[DjangoIntegration()], +# traces_sample_rate=0.1, +# send_default_pii=False +# ) \ No newline at end of file diff --git a/techblog_cms/static/css/style.css b/techblog_cms/static/css/style.css index bf3b62c..286489c 100644 --- a/techblog_cms/static/css/style.css +++ b/techblog_cms/static/css/style.css @@ -1,14 +1,61 @@ /* Custom styles that extend Tailwind */ body { - padding-top: 80px; + padding-top: 9rem; + padding-top: calc(9rem + env(safe-area-inset-top)); min-height: 100vh; } +#mobile-menu-button { + position: relative; +} + +#mobile-menu-button .hamburger-line { + position: absolute; + left: 50%; + width: 1.5rem; + height: 0.125rem; + background-color: currentColor; + border-radius: 9999px; + transform: translate(-50%, -50%); + transition: transform 0.25s ease, opacity 0.2s ease, top 0.25s ease; +} + +#mobile-menu-button .hamburger-line-top { + top: calc(50% - 0.45rem); +} + +#mobile-menu-button .hamburger-line-middle { + top: 50%; +} + +#mobile-menu-button .hamburger-line-bottom { + top: calc(50% + 0.45rem); +} + +#mobile-menu-button.open .hamburger-line-top, +#mobile-menu-button.open .hamburger-line-bottom { + top: 50%; +} + +#mobile-menu-button.open .hamburger-line-top { + transform: translate(-50%, -50%) rotate(45deg); +} + +#mobile-menu-button.open .hamburger-line-middle { + opacity: 0; +} + +#mobile-menu-button.open .hamburger-line-bottom { + transform: translate(-50%, -50%) rotate(-45deg); +} + /* Sidebar styles */ .sidebar { position: sticky; - top: 80px; - height: calc(100vh - 80px); + top: 5rem; + top: calc(5rem + env(safe-area-inset-top)); + height: calc(100vh - 5rem); + height: calc(100vh - (5rem + env(safe-area-inset-top))); overflow-y: auto; background-color: white; padding: 1rem; @@ -35,6 +82,11 @@ body { /* Responsive breakpoints */ @media (min-width: 768px) { + body { + padding-top: 5rem; + padding-top: calc(5rem + env(safe-area-inset-top)); + } + .article-card { flex: 0 0 calc(50% - 0.5rem); } diff --git a/techblog_cms/static/js/main.js b/techblog_cms/static/js/main.js index fd47f2c..066af2b 100644 --- a/techblog_cms/static/js/main.js +++ b/techblog_cms/static/js/main.js @@ -1,4 +1,62 @@ -document.addEventListener('DOMContentLoaded', function() { - // Initialize any JavaScript functionality - // Mobile menu toggle functionality can be added here -}); \ No newline at end of file +document.addEventListener('DOMContentLoaded', () => { + const menuButton = document.getElementById('mobile-menu-button'); + const mobileMenu = document.getElementById('mobile-menu'); + + if (!menuButton || !mobileMenu) { + return; + } + + const openLabel = 'メインメニューを開く'; + const closeLabel = 'メインメニューを閉じる'; + const srLabel = menuButton.querySelector('[data-menu-button-label]'); + + const setMenuState = (shouldOpen) => { + if (shouldOpen) { + mobileMenu.classList.remove('hidden'); + menuButton.setAttribute('aria-expanded', 'true'); + menuButton.classList.add('open'); + menuButton.setAttribute('aria-label', closeLabel); + if (srLabel) { + srLabel.textContent = closeLabel; + } + } else { + mobileMenu.classList.add('hidden'); + menuButton.setAttribute('aria-expanded', 'false'); + menuButton.classList.remove('open'); + menuButton.setAttribute('aria-label', openLabel); + if (srLabel) { + srLabel.textContent = openLabel; + } + } + }; + + const closeMenu = () => setMenuState(false); + + menuButton.addEventListener('click', (event) => { + event.preventDefault(); + const shouldOpen = mobileMenu.classList.contains('hidden'); + setMenuState(shouldOpen); + }); + + mobileMenu.querySelectorAll('a').forEach((link) => { + link.addEventListener('click', closeMenu); + }); + + document.addEventListener('click', (event) => { + if ( + !mobileMenu.classList.contains('hidden') && + !mobileMenu.contains(event.target) && + !menuButton.contains(event.target) + ) { + closeMenu(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + closeMenu(); + } + }); + + setMenuState(false); +}); diff --git a/techblog_cms/templates/article_detail.html b/techblog_cms/templates/article_detail.html index 617915e..0feb26e 100644 --- a/techblog_cms/templates/article_detail.html +++ b/techblog_cms/templates/article_detail.html @@ -226,7 +226,10 @@

{{ article.title }}

- Published: {{ article.created_at|date:"M d, Y" }} + Published: {{ article.created_at|date:"M d, Y H:i" }} + {% if article.updated_at and article.updated_at != article.created_at %} + Updated: {{ article.updated_at|date:"M d, Y H:i" }} + {% endif %} {% if article.category %} Category: diff --git a/techblog_cms/templates/article_editor.html b/techblog_cms/templates/article_editor.html index 37d0e20..b9c5fb2 100644 --- a/techblog_cms/templates/article_editor.html +++ b/techblog_cms/templates/article_editor.html @@ -5,13 +5,13 @@
-

記事エディタ

+

{% if article %}記事編集{% else %}記事エディタ{% endif %}

{% if error %}
{{ error }}
{% endif %} -
+ {% if not IS_TESTING %} {% csrf_token %} {% endif %} @@ -19,6 +19,7 @@

記事エディタ

@@ -40,13 +41,13 @@

記事エディタ

+ placeholder="ここに記事を書いてください...">{{ content_value|default:'' }}
diff --git a/techblog_cms/templates/base.html b/techblog_cms/templates/base.html index 5c4964c..608fddf 100644 --- a/techblog_cms/templates/base.html +++ b/techblog_cms/templates/base.html @@ -31,12 +31,12 @@ {% include 'components/header.html' %} {% endblock %} -
-
+
+
{% block sidebar %} {% include 'components/sidebar.html' %} {% endblock %} - + {% block content %} {% include 'components/main_content.html' %} {% endblock %} @@ -44,7 +44,7 @@
{% block scripts %} - + {% endblock %} diff --git a/techblog_cms/templates/components/header.html b/techblog_cms/templates/components/header.html index d0f2368..45c2679 100644 --- a/techblog_cms/templates/components/header.html +++ b/techblog_cms/templates/components/header.html @@ -1,40 +1,55 @@
diff --git a/techblog_cms/templates/components/sidebar.html b/techblog_cms/templates/components/sidebar.html index 06f458f..22c2e95 100644 --- a/techblog_cms/templates/components/sidebar.html +++ b/techblog_cms/templates/components/sidebar.html @@ -1,4 +1,4 @@ -