Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
53 changes: 53 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Backend CI

on:
pull_request:
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"
push:
branches:
- main
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"

concurrency:
group: backend-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
checks:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
env:
DJANGO_ENV: development
DJANGO_DEBUG: "true"
DJANGO_SECRET_KEY: ci-insecure-secret-key
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: backend/requirements.txt

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Django check
run: python manage.py check

- name: Migrations check
run: python manage.py makemigrations --check --dry-run

- name: Run tests
run: python manage.py test
45 changes: 45 additions & 0 deletions .github/workflows/due-reminders.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Due Reminders

on:
schedule:
- cron: "*/10 * * * *"
workflow_dispatch:

jobs:
send-reminders:
if: ${{ secrets.PROD_DATABASE_URL != '' }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
env:
DJANGO_ENV: production
DJANGO_DEBUG: "false"
DJANGO_SECRET_KEY: ${{ secrets.PROD_DJANGO_SECRET_KEY }}
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
ALLOWED_HOSTS: ${{ secrets.PROD_ALLOWED_HOSTS }}
EMAIL_BACKEND: django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST: ${{ secrets.PROD_EMAIL_HOST }}
EMAIL_PORT: ${{ secrets.PROD_EMAIL_PORT }}
EMAIL_USE_TLS: "true"
EMAIL_HOST_USER: ${{ secrets.PROD_EMAIL_HOST_USER }}
EMAIL_HOST_PASSWORD: ${{ secrets.PROD_EMAIL_HOST_PASSWORD }}
DEFAULT_FROM_EMAIL: ${{ secrets.PROD_DEFAULT_FROM_EMAIL }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: backend/requirements.txt

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Run due reminders command
run: python manage.py send_due_reminders
25 changes: 25 additions & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Secret Scan

on:
pull_request:
push:
branches:
- main
- master

jobs:
gitleaks:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 changes: 20 additions & 0 deletions .github/workflows/prod-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Production Smoke

on:
workflow_dispatch:

jobs:
smoke:
runs-on: ubuntu-latest
steps:
- name: Health endpoint
run: |
curl --fail --silent --show-error "${{ secrets.PROD_BACKEND_HEALTH_URL }}"

- name: Frontend homepage
run: |
curl --fail --silent --show-error "${{ secrets.PROD_FRONTEND_URL }}"

- name: Frontend manifest
run: |
curl --fail --silent --show-error "${{ secrets.PROD_FRONTEND_URL }}/manifest.json"
10 changes: 10 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
title = "Boardly Gitleaks Config"

[allowlist]
description = "Allow local/dev placeholders that are not real credentials."
regexes = [
'''replace-with-long-random-secret''',
'''ci-insecure-secret-key''',
'''your-login''',
'''your-password''',
]
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ For authenticated smoke tests, set:
- Backend uses ASGI (`daphne`) for HTTP + WebSocket support.
- Frontend production build is served via `nginx`.
- CI workflow lives in `.github/workflows/frontend-ci.yml`.
- Backend CI workflow lives in `.github/workflows/backend-ci.yml`.
- Secret scan workflow lives in `.github/workflows/gitleaks.yml`.

### Realtime Config (Redis + WS reconnect)
- Backend channel layer:
Expand All @@ -76,6 +78,29 @@ For authenticated smoke tests, set:
- `REACT_APP_WS_MAX_RECONNECT_MS`
- `REACT_APP_WS_RECONNECT_JITTER_MS`

### Observability
- Backend Sentry:
- `SENTRY_DSN`
- `SENTRY_TRACES_SAMPLE_RATE`
- Frontend Sentry:
- `REACT_APP_SENTRY_DSN`
- `REACT_APP_SENTRY_ENV`
- `REACT_APP_SENTRY_TRACES_SAMPLE_RATE`
- Health endpoints:
- `GET /api/health/`
- `GET /healthz/`

### Security Baseline
- Rotate all leaked/legacy SMTP credentials and update production secrets.
- Dependabot config: `.github/dependabot.yml`.
- Secret scanning in CI: `gitleaks` workflow.
- For GitHub branch protection (requires repository admin), run:
- `pwsh scripts/github/apply-branch-protection.ps1`
- To enable GitHub secret scanning/push protection (requires repository admin), run:
- `pwsh scripts/github/enable-security-analysis.ps1`
- Production deploy checklist:
- `docs/ops/deploy-checklist.md`

## License
MIT License. See `LICENSE`.

Expand Down
7 changes: 6 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ SECURE_HSTS_INCLUDE_SUBDOMAINS=True
SECURE_HSTS_PRELOAD=True
SECURE_PROXY_SSL_HEADER_ENABLED=True

# Observability
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=0.1

# PostgreSQL (recommended for production)
DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require

Expand All @@ -20,7 +24,8 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require
# If empty, backend falls back to in-memory layer (single instance/dev only).
CHANNEL_REDIS_URL=redis://default:password@host:6379/0

# SMTP
# SMTP (required in production when EMAIL_BACKEND is SMTP)
# Never commit real credentials to git.
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
Expand Down
46 changes: 44 additions & 2 deletions backend/boardly_project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os
import warnings
import logging
import dj_database_url
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
Expand Down Expand Up @@ -43,9 +44,41 @@ def _env_int(name: str, default: int) -> int:
raise ImproperlyConfigured(f'{name} must be an integer.') from exc


def _env_float(name: str, default: float) -> float:
value = os.getenv(name)
if value is None:
return default
try:
return float(value)
except ValueError as exc:
raise ImproperlyConfigured(f'{name} must be a float.') from exc


DJANGO_ENV = os.getenv('DJANGO_ENV', 'development').strip().lower()
IS_PRODUCTION = DJANGO_ENV in {'production', 'prod'}

SENTRY_DSN = os.getenv('SENTRY_DSN', '').strip()
SENTRY_TRACES_SAMPLE_RATE = _env_float('SENTRY_TRACES_SAMPLE_RATE', 0.0)

if SENTRY_DSN:
try:
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import LoggingIntegration

sentry_sdk.init(
dsn=SENTRY_DSN,
environment=DJANGO_ENV,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
send_default_pii=False,
)
except Exception as exc:
warnings.warn(f'Failed to initialize Sentry: {exc}', RuntimeWarning)

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key-change-me')
if IS_PRODUCTION and (not os.getenv('DJANGO_SECRET_KEY') or SECRET_KEY == 'unsafe-dev-key-change-me'):
Expand Down Expand Up @@ -321,8 +354,8 @@ def _split_env(value: str) -> list[str]:
EMAIL_USE_TLS = _env_bool('EMAIL_USE_TLS', True)

# У dev автоматично вмикаємо SMTP, якщо креденшали задані.
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', 'tititimurc@gmail.com').strip()
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', 'fptmriinkxgsseln').strip()
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '').strip()
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '').strip()

EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', '').strip()
if not EMAIL_BACKEND:
Expand All @@ -335,6 +368,15 @@ def _split_env(value: str) -> list[str]:
else 'django.core.mail.backends.smtp.EmailBackend'
)

if (
IS_PRODUCTION
and EMAIL_BACKEND == 'django.core.mail.backends.smtp.EmailBackend'
and (not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD)
):
raise ImproperlyConfigured(
'SMTP backend requires EMAIL_HOST_USER and EMAIL_HOST_PASSWORD in production.'
)

DEFAULT_FROM_EMAIL = os.getenv(
'DEFAULT_FROM_EMAIL',
f'Boardly <{EMAIL_HOST_USER}>' if EMAIL_HOST_USER else 'Boardly <noreply@boardly.com>'
Expand Down
2 changes: 2 additions & 0 deletions backend/boardly_project/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from core.api.views import HealthCheckView

urlpatterns = [
path('admin/', admin.site.urls),
path('healthz/', HealthCheckView.as_view(), name='healthz'),

# Маршрути аутентифікації Djoser (реєстрація, логін, токени)
path('api/auth/', include('djoser.urls')),
Expand Down
12 changes: 12 additions & 0 deletions backend/core/api/serializers/boards.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from rest_framework import serializers
from django.contrib.auth.models import User
from core.models import Board, Membership, Label, Activity
from core.services.board_templates import BOARD_TEMPLATES
from .users import UserSerializer
import logging

Expand Down Expand Up @@ -39,6 +40,12 @@ class BoardSerializer(serializers.ModelSerializer):
members = MembershipSerializer(source='membership_set', many=True, read_only=True)
lists = serializers.SerializerMethodField()
labels = LabelSerializer(many=True, read_only=True)
template_key = serializers.ChoiceField(
choices=tuple((key, key) for key in BOARD_TEMPLATES.keys()),
required=False,
write_only=True,
default='blank',
)
# Динамічне поле: чи є дошка в обраному у поточного користувача
is_favorite = serializers.SerializerMethodField()

Expand All @@ -48,12 +55,17 @@ class Meta:
'id', 'title', 'description', 'background_url',
'is_archived', 'is_favorite', 'owner', 'created_at',
'invite_link', 'members', 'lists', 'labels',
'template_key',
'dev_can_create_cards', 'dev_can_edit_assigned_cards',
'dev_can_archive_assigned_cards', 'dev_can_join_card',
'dev_can_create_lists'
)
read_only_fields = ('owner', 'invite_link', 'created_at')

def create(self, validated_data):
validated_data.pop('template_key', None)
return super().create(validated_data)

def get_lists(self, obj):
from .cards import ListSerializer
queryset = obj.lists.all()
Expand Down
18 changes: 16 additions & 2 deletions backend/core/api/serializers/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Meta:
model = Card
fields = (
'id', 'title', 'description', 'card_color', 'cover_size', 'order', 'due_date',
'is_completed', 'is_archived', 'is_public', # <-- ДОДАНО
'is_completed', 'is_archived', 'is_public', 'recurrence', 'reminder_minutes_before',
'list', 'board', 'board_title',
'members', 'labels', 'label_ids',
'checklists', 'attachments', 'comments'
Expand Down Expand Up @@ -70,7 +70,21 @@ class MyCardSerializer(serializers.ModelSerializer):
class Meta:
model = Card
# Додано is_public
fields = ('id', 'title', 'description', 'card_color', 'cover_size', 'due_date', 'is_archived', 'board', 'list', 'labels', 'is_public')
fields = (
'id',
'title',
'description',
'card_color',
'cover_size',
'due_date',
'is_archived',
'board',
'list',
'labels',
'is_public',
'recurrence',
'reminder_minutes_before',
)

def get_labels(self, obj):
labels = Label.objects.filter(cardlabel__card=obj)
Expand Down
Loading
Loading