diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6395e30 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,19 @@ +# CODEOWNERS for Pinewood One +# These owners are requested for review when someone opens a PR touching +# matching paths. See: +# https://docs.github.com/articles/about-code-owners + +# Default owner for everything in the repo. +* @AdamEXu + +# Top-level applications. +/frontend/ @AdamEXu +/backend/ @AdamEXu + +# Security-sensitive backend areas (extra scrutiny on review). +/backend/auth/ @AdamEXu +/backend/db/ @AdamEXu +/backend/services/scraper/ @AdamEXu + +# Repository automation and policy. +/.github/ @AdamEXu diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4300f21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: Bug report +description: Report a problem with Pinewood One +labels: + - bug +body: + - type: markdown + attributes: + value: >- + Thanks for taking the time to file a bug! For **security + vulnerabilities**, do not use this form — report privately via a GitHub + security advisory (see .github/SECURITY.md). + - type: dropdown + id: area + attributes: + label: Area + description: Which part of the app is affected? + options: + - frontend + - backend + - scraper + - convex + - other + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, including what you expected to happen. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: How can we reproduce the problem? + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Browser/OS, and whether this is local dev or a deployment. Do NOT paste secrets, tokens, or real student data. + placeholder: e.g. Chrome 130 on macOS 14, local dev + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b4bd258 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/Pinewood-Tech-Club/one/security/advisories/new + about: >- + Please do NOT file public issues for security problems. Report + vulnerabilities privately via a GitHub security advisory. See + .github/SECURITY.md for details. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8d2106f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest an idea or improvement for Pinewood One +labels: + - enhancement +body: + - type: markdown + attributes: + value: Thanks for suggesting an improvement! Please describe the problem before the solution. + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem are you trying to solve? Why does it matter? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe what you'd like to happen. Include alternatives you've considered. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + description: Which part of the app does this affect? + options: + - frontend + - backend + - scraper + - convex + - other + validations: + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..9e5961d --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,65 @@ +# Security Policy + +Pinewood One handles **sensitive student data** — Schoology grades, schedules, +and assignments — as well as **OAuth tokens** and session credentials. We take +security reports seriously and appreciate responsible disclosure. + +## Reporting a vulnerability + +**Please do not open a public GitHub issue for security problems.** Public +issues disclose the vulnerability before it can be fixed. + +**Primary channel — GitHub private security advisory:** + +> https://github.com/Pinewood-Tech-Club/one/security/advisories/new + +Use the "Report a vulnerability" button on that page to open a private advisory. +This keeps the report confidential and lets us collaborate on a fix. + +If you cannot use the advisory workflow, contact a maintainer +(**@AdamEXu**) privately through GitHub and ask for a secure channel. Please do +not post vulnerability details in public comments, discussions, or issues. + +### What to include + +- A description of the vulnerability and its impact. +- Steps to reproduce (proof of concept if possible). +- Affected component(s) and any relevant version/commit. +- Any suggested remediation, if you have one. + +## Scope + +We are especially interested in reports affecting: + +- **Authentication & sessions** — Google OAuth login, session issuance, + expiration, and cookie handling. +- **OAuth token storage** — encryption at rest (Fernet), token handling, and + any path where tokens could leak (logs, error responses). +- **Scraper** (`backend/services/scraper/`) — server-side request forgery + (SSRF) via scraped URLs, and job-lease integrity / race conditions. +- **Chat SSE** — the server-sent-events chat stream (authorization, data + leakage across users). +- **IDOR / broken access control** — any endpoint returning user data without + verifying ownership. + +## Please avoid + +To protect real users while testing, please **do not**: + +- Scan, probe, or attack the **production Cloudflare tunnel** or any live + deployment. +- Access, exfiltrate, or modify **real student data** or another person's + account. +- Run automated scanners against production infrastructure. + +Test against a local development instance whenever possible. If you believe a +finding can only be demonstrated against production, describe it in the private +advisory and we will help reproduce it safely. + +## Our commitment + +- We will acknowledge your report as soon as we can. +- We will keep you informed as we investigate and remediate. +- We will credit reporters who wish to be acknowledged, once a fix is released. + +Thank you for helping keep Pinewood One and its users safe. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1b921fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + # Frontend JavaScript/TypeScript dependencies (Next.js app) + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "frontend" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # Backend Python dependencies (Flask app) + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "backend" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # GitHub Actions workflow dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "ci" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c4fee01 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ + + +## What & why + + + +## Area + + + +- [ ] Frontend +- [ ] Backend +- [ ] Scraper +- [ ] Convex +- [ ] Infra / tooling + +## How tested + + + +## Security checklist + + + +- [ ] Endpoints returning user data enforce **ownership / authorization** (no IDOR — a user cannot read another user's data). +- [ ] `@auth_required` is applied to routes that need authentication. +- [ ] No **SSRF**: any outbound/scraped URL is validated (scheme + host) before it is fetched. +- [ ] No secrets committed — no `.env`, `*.db`, keys, or credentials in the diff. +- [ ] Flask **debug mode is not forced on** in a production code path. +- [ ] OAuth **tokens stay encrypted** at rest and are **never logged** or returned in responses. + +## Checks + +- [ ] `cd frontend && pnpm lint` passes. +- [ ] Backend still starts and affected endpoints work (`make dev-backend`). diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..e41cb4d --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,46 @@ +name: backend + +on: + pull_request: + paths: + - "backend/**" + - ".github/workflows/backend.yml" + push: + branches: + - main + - dev + paths: + - "backend/**" + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff==0.9.6 + + - name: Ruff lint + run: ruff check . + + # NOTE: `ruff format --check .` is intentionally omitted for now. + # A first-time format pass would reformat ~46 files, which does not + # belong in a security-focused PR. Enable it as a follow-up once a + # dedicated formatting commit lands. + + - name: Byte-compile + run: python -m compileall -q -x "env/" . + + - name: Install test dependencies + run: pip install -r requirements.txt -r requirements-test.txt + + - name: Run tests + run: python -m pytest diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml new file mode 100644 index 0000000..f1e2a62 --- /dev/null +++ b/.github/workflows/bandit.yml @@ -0,0 +1,37 @@ +name: Bandit + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + security-events: write + contents: read + +jobs: + bandit: + name: Bandit Python SAST + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Bandit + run: pip install 'bandit[sarif]' + + - name: Run Bandit + run: bandit -r backend -x backend/env,backend/__pycache__ -f sarif -o bandit.sarif || true + + - name: Upload SARIF results + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: bandit.sarif diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml new file mode 100644 index 0000000..2fd0fc4 --- /dev/null +++ b/.github/workflows/ci-frontend.yml @@ -0,0 +1,47 @@ +name: CI Frontend + +on: + pull_request: + branches: [main] + paths: + - 'frontend/**' + - '.github/workflows/ci-frontend.yml' + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Hard gate: the type checker is clean today and must stay clean. + - name: Typecheck + run: pnpm typecheck + + # Non-blocking ratchet: ~71 pre-existing eslint violations. Runs for + # visibility; flip to a hard gate once the backlog is burned down. + - name: Lint + run: pnpm lint + continue-on-error: true + + # Non-blocking for now: `next build` needs NEXT_PUBLIC_* env configured + # as repo secrets/vars to pass reliably. Make it a hard gate once those + # are wired up in repo settings. + - name: Build + run: pnpm build + continue-on-error: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6a651a1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "27 4 * * 1" + +permissions: + security-events: write + actions: read + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + - python + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-extended + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1f635b4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=1024"] + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.6 + hooks: + - id: ruff + args: ["--fix"] + files: ^backend/ + - id: ruff-format + files: ^backend/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c5a0dde --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Pinewood One + +Thanks for your interest in contributing! Pinewood One is a full-stack app that +integrates Schoology data (grades, schedules, assignments) with a Next.js +frontend and a Flask backend. Because it handles sensitive student data and +OAuth tokens, we hold contributions to a high security bar. Please read this +guide before opening a pull request. + +## Getting set up + +The repository ships a `Makefile` that wraps the dev tooling. From the repo root: + +```bash +make setup # one-time: install deps and prepare the dev environment +make init # initialize local config / databases +make doctor # verify your environment is healthy +make secret-export # export the local secrets needed for development +``` + +To run the app during development: + +```bash +make dev # runs doctor, then starts the full stack +make dev-frontend # frontend only (Next.js) +make dev-backend # backend only (Flask) +make dev-convex # Convex dev deployment +``` + +Ports: + +- Frontend: **3112** +- Backend: **3111** + +If something looks off, run `make doctor` first — it checks each component +(`--component frontend`, `--component backend`, `--component convex`). + +## Workflow + +1. **Branch off `main`.** Create a topic branch for your change; do not commit + directly to `main`. +2. Make your change, keeping it focused and reasonably small. +3. **Lint the frontend** before pushing: + ```bash + cd frontend && pnpm lint + ``` +4. If you touch the backend, make sure it still starts (`make dev-backend`) and + that any affected endpoints behave as expected. +5. Open a pull request against `main` and **fill out the PR template**, + including the security checklist. PRs with an incomplete security checklist + will not be merged. + +## Security expectations + +Every contributor is responsible for not regressing security. When you open a +PR, confirm each item in the security checklist, in particular: + +- Endpoints that return user data enforce ownership / authorization (no IDOR) + and apply the `@auth_required` decorator where authentication is needed. +- Any outbound URL (especially in the scraper) is validated — no SSRF. +- OAuth tokens stay encrypted at rest and are never logged. +- Flask debug mode is never forced on in a production code path. + +**Never commit secrets.** Do not commit `.env` files, `*.db` databases, private +keys, or any credential. These paths are already covered by `.gitignore` — keep +them out of your diffs. + +## Reporting a vulnerability + +Do **not** open a public issue for security problems. Please follow the private +process described in [`.github/SECURITY.md`](.github/SECURITY.md). + +## Questions + +Open a regular issue using one of the issue templates (bug report or feature +request) for non-security topics. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51c8349 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pinewood Tech Club + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/api/routes.py b/backend/api/routes.py index 47c183b..74e7859 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -6,9 +6,11 @@ from hashlib import sha256 from flask import Blueprint, Response, jsonify, request, stream_with_context -from config import Config + from auth.middleware import auth_required -from onboarding import get_user as convex_get_user, update_onboarding_step, save_consent +from config import Config +from onboarding import get_user as convex_get_user +from onboarding import save_consent, update_onboarding_step from services.chat import convex_sync, live_stream TERMINAL_CHAT_STATUSES = {"completed", "failed", "cancelled"} @@ -88,7 +90,7 @@ def start_onboarding(user): Updates onboarding_step from "welcome" to "connect_lms". """ try: - result = update_onboarding_step( + update_onboarding_step( Config.CONVEX_URL, str(user["id"]), "connect_lms" @@ -128,7 +130,7 @@ def save_user_consent(user): "version": str(version) } - result = save_consent( + save_consent( Config.CONVEX_URL, str(user["id"]), consent diff --git a/backend/app.py b/backend/app.py index 3bbb504..04751cb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2,18 +2,21 @@ Pinewood One Backend - Main Application Entry Point """ import os + from flask import Flask, jsonify from flask_cors import CORS -from config import Config -from db.init import init_db -from extensions import limiter + +from api.routes import api_bp # Import blueprints from auth.routes import auth_bp -from api.routes import api_bp +from config import Config +from db.init import init_db +from extensions import limiter from internal_chat.routes import internal_chat_bp -from schoology.routes import oauth_bp as schoology_oauth_bp, schoology_api_bp from mobile.routes import mobile_bp +from schoology.routes import oauth_bp as schoology_oauth_bp +from schoology.routes import schoology_api_bp def create_app(): diff --git a/backend/auth/google.py b/backend/auth/google.py index ae2ed8f..b4fe434 100644 --- a/backend/auth/google.py +++ b/backend/auth/google.py @@ -5,9 +5,10 @@ import os import queue import threading +from urllib.parse import urlencode import requests -from urllib.parse import urlencode + from config import Config logger = logging.getLogger(__name__) diff --git a/backend/auth/jwt_utils.py b/backend/auth/jwt_utils.py index d4911ed..d0f9a70 100644 --- a/backend/auth/jwt_utils.py +++ b/backend/auth/jwt_utils.py @@ -1,8 +1,8 @@ """ JWT utilities for backend-issued RS256 tokens. """ -import os import base64 +import os import secrets from datetime import datetime, timedelta, timezone from pathlib import Path diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index 754cc96..e6acfda 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -2,7 +2,9 @@ Authentication middleware """ from functools import wraps -from flask import session, jsonify + +from flask import jsonify, session + from db.sessions import get_session diff --git a/backend/auth/mobile_middleware.py b/backend/auth/mobile_middleware.py index 17d17ef..2e0e3fa 100644 --- a/backend/auth/mobile_middleware.py +++ b/backend/auth/mobile_middleware.py @@ -3,7 +3,7 @@ """ from functools import wraps -from flask import jsonify, g, request +from flask import g, jsonify, request from auth.jwt_utils import verify_mobile_access_token from db.users import get_user_by_id diff --git a/backend/auth/routes.py b/backend/auth/routes.py index bfcce77..3ad7a61 100644 --- a/backend/auth/routes.py +++ b/backend/auth/routes.py @@ -5,11 +5,13 @@ import logging import secrets import threading -from flask import Blueprint, redirect, request, session, jsonify + +from flask import Blueprint, jsonify, redirect, request, session + +from auth.google import exchange_code_for_token, get_google_auth_url, get_user_info from config import Config -from auth.google import get_google_auth_url, exchange_code_for_token, get_user_info -from db.users import get_or_create_user from db.sessions import create_session, delete_session +from db.users import get_or_create_user from onboarding import get_or_create_user as convex_get_or_create_user logger = logging.getLogger(__name__) @@ -46,7 +48,7 @@ def auth_google(): def auth_google_callback(): """Handle Google OAuth callback""" logger.debug("Google OAuth callback hit") - logger.debug(f"Request args: {request.args}") + logger.debug("Request args keys: %s", sorted(request.args.keys())) try: returned_state = request.args.get("state", "") expected_state = session.pop("google_oauth_state", None) diff --git a/backend/db/encryption.py b/backend/db/encryption.py index 0e96aba..c294612 100644 --- a/backend/db/encryption.py +++ b/backend/db/encryption.py @@ -2,6 +2,7 @@ Encryption utilities for secure token storage """ from cryptography.fernet import Fernet + from config import Config ENCRYPTION_KEY = Config.ENCRYPTION_KEY diff --git a/backend/db/sessions.py b/backend/db/sessions.py index b38f5d5..7e8b8f4 100644 --- a/backend/db/sessions.py +++ b/backend/db/sessions.py @@ -3,6 +3,7 @@ """ import secrets from datetime import datetime, timedelta + from config import Config from db.pool import get_conn from db.users import get_user_by_id diff --git a/backend/db/tokens.py b/backend/db/tokens.py index 46a7b72..3297311 100644 --- a/backend/db/tokens.py +++ b/backend/db/tokens.py @@ -5,8 +5,8 @@ from datetime import datetime from config import Config -from db.pool import get_conn from db.encryption import encrypt_token +from db.pool import get_conn def hash_schoology_request_token(request_token: str) -> str: diff --git a/backend/db/users.py b/backend/db/users.py index f8a9935..c28084d 100644 --- a/backend/db/users.py +++ b/backend/db/users.py @@ -2,6 +2,7 @@ User database operations """ from datetime import datetime + from config import Config from db.pool import get_conn diff --git a/backend/extensions.py b/backend/extensions.py index 2a417bc..ba957d2 100644 --- a/backend/extensions.py +++ b/backend/extensions.py @@ -4,7 +4,6 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address - limiter = Limiter( key_func=get_remote_address, default_limits=[], diff --git a/backend/internal_chat/routes.py b/backend/internal_chat/routes.py index e9ee853..2653b33 100644 --- a/backend/internal_chat/routes.py +++ b/backend/internal_chat/routes.py @@ -2,11 +2,11 @@ Internal chat execution routes. """ import logging -from pathlib import Path import subprocess -import threading import sys +import threading import time +from pathlib import Path from flask import Blueprint, jsonify, request diff --git a/backend/mobile/routes.py b/backend/mobile/routes.py index e43c491..18c94f4 100644 --- a/backend/mobile/routes.py +++ b/backend/mobile/routes.py @@ -1,15 +1,15 @@ """ Mobile API routes (/api/mobile/v1/*). """ -from flask import Blueprint, jsonify, redirect, request, session, g +from flask import Blueprint, g, jsonify, redirect, request, session from flask_limiter.util import get_remote_address from auth.mobile_middleware import mobile_auth_required from config import Config from db.sessions import create_session from extensions import limiter -from services.mobile import service from onboarding import get_user as convex_get_user +from services.mobile import service from services.schoology.refresh import start_schoology_refresh_for_user mobile_bp = Blueprint("mobile_api", __name__, url_prefix="/api/mobile/v1") diff --git a/backend/onboarding/__init__.py b/backend/onboarding/__init__.py index 79a8dac..90f3921 100644 --- a/backend/onboarding/__init__.py +++ b/backend/onboarding/__init__.py @@ -4,9 +4,9 @@ from .convex_sync import ( get_or_create_user, get_user, + save_consent, update_onboarding_step, update_schoology_connected, - save_consent, ) __all__ = [ diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..b9ccd49 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +ruff==0.9.6 +pytest==8.3.4 +pytest-cov==6.0.0 +# mypy==1.15.0 diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 0000000..6044b98 --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,14 @@ +target-version = "py312" +line-length = 100 +extend-exclude = ["env", "__pycache__"] + +# Start with the highest-signal, lowest-churn rule sets (pyflakes + import +# sorting). UP (pyupgrade) and B (flake8-bugbear) are valuable ratchet +# follow-ups but currently surface high-churn changes; enable them once the +# initial cleanup lands. +[lint] +select = ["F", "I"] +ignore = ["E501"] + +[format] +quote-style = "double" diff --git a/backend/schoology/routes.py b/backend/schoology/routes.py index 840def0..8c7d799 100644 --- a/backend/schoology/routes.py +++ b/backend/schoology/routes.py @@ -2,22 +2,22 @@ Schoology API routes. """ from flask import Blueprint, jsonify, redirect, request -from config import Config + from auth.middleware import auth_required -from services.schoology import start_oauth, complete_oauth -from services.schoology.convex_sync import sync_profile_picture -from services.schoology.refresh import start_schoology_refresh_for_user -from services.schoology.runtime import create_schoology_service +from config import Config +from db.encryption import decrypt_token from db.tokens import ( + delete_schoology_tokens, + get_schoology_request_token_record, + save_schoology_access_tokens, save_schoology_credentials, save_schoology_request_tokens, - save_schoology_access_tokens, - get_schoology_request_token_record, - delete_schoology_tokens ) -from db.encryption import decrypt_token -from onboarding import update_schoology_connected, update_onboarding_step -from services.schoology import SchoologyService +from onboarding import update_onboarding_step, update_schoology_connected +from services.schoology import SchoologyService, complete_oauth, start_oauth +from services.schoology.convex_sync import sync_profile_picture +from services.schoology.refresh import start_schoology_refresh_for_user +from services.schoology.runtime import create_schoology_service # Blueprint for /oauth/schoology/* routes oauth_bp = Blueprint('schoology_oauth', __name__, url_prefix='/oauth/schoology') @@ -222,7 +222,7 @@ def schoology_courses(user): print(f"[DEBUG] Failed to create Schoology service for user_id: {user['id']}") return jsonify({"error": "Schoology account not connected"}), 400 - print(f"[DEBUG] Fetching courses from Schoology API...") + print("[DEBUG] Fetching courses from Schoology API...") courses = service.get_courses() # Automatically syncs to Convex print(f"[DEBUG] Retrieved {len(courses)} courses") diff --git a/backend/services/chat/schoology_tools.py b/backend/services/chat/schoology_tools.py index 730d7f1..1b59e62 100644 --- a/backend/services/chat/schoology_tools.py +++ b/backend/services/chat/schoology_tools.py @@ -3,18 +3,18 @@ """ from __future__ import annotations -from dataclasses import dataclass import json -from pathlib import Path import re +from dataclasses import dataclass +from pathlib import Path from typing import Any from itsdangerous import BadSignature, URLSafeSerializer from config import Config from services.schoology.runtime import create_schoology_service -from services.scraper.extraction import read_extracted_attachment_text from services.scraper import store as scraper_store +from services.scraper.extraction import read_extracted_attachment_text class SchoologyToolError(RuntimeError): diff --git a/backend/services/chat/service.py b/backend/services/chat/service.py index 6dd474c..129949e 100644 --- a/backend/services/chat/service.py +++ b/backend/services/chat/service.py @@ -7,6 +7,7 @@ import threading import time from dataclasses import dataclass +from typing import Any from config import Config diff --git a/backend/services/chat/worker.py b/backend/services/chat/worker.py index c64c80e..23e5e7b 100644 --- a/backend/services/chat/worker.py +++ b/backend/services/chat/worker.py @@ -5,9 +5,14 @@ import sys import time -from . import ChatConfigurationError, ChatContractError, ChatGenerationNotFoundError, run_generation -from . import convex_sync -from . import live_stream +from . import ( + ChatConfigurationError, + ChatContractError, + ChatGenerationNotFoundError, + convex_sync, + live_stream, + run_generation, +) logger = logging.getLogger(__name__) diff --git a/backend/services/mobile/service.py b/backend/services/mobile/service.py index 14d3058..a83e55d 100644 --- a/backend/services/mobile/service.py +++ b/backend/services/mobile/service.py @@ -8,7 +8,7 @@ import re import secrets from datetime import timedelta -from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import requests from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer @@ -16,12 +16,14 @@ from auth.google import get_user_info from auth.jwt_utils import create_convex_token, create_mobile_access_token from config import Config -from db.encryption import decrypt_token, encrypt_token from db import mobile as mobile_db +from db.encryption import decrypt_token, encrypt_token from db.tokens import save_schoology_access_tokens from db.users import get_or_create_user, get_user_by_id from onboarding import ( get_or_create_user as convex_get_or_create_user, +) +from onboarding import ( update_onboarding_step, update_schoology_connected, ) diff --git a/backend/services/schoology/__init__.py b/backend/services/schoology/__init__.py index 2dfad9f..a1264f3 100644 --- a/backend/services/schoology/__init__.py +++ b/backend/services/schoology/__init__.py @@ -6,7 +6,7 @@ """ from .client import SchoologyService -from .oauth import start_oauth, complete_oauth +from .oauth import complete_oauth, start_oauth __version__ = "0.1.0" __all__ = ["SchoologyService", "start_oauth", "complete_oauth"] diff --git a/backend/services/schoology/client.py b/backend/services/schoology/client.py index 185a453..b6ddafb 100644 --- a/backend/services/schoology/client.py +++ b/backend/services/schoology/client.py @@ -4,18 +4,19 @@ import ipaddress import json import socket -from pathlib import Path +from datetime import datetime, timedelta, timezone +from urllib.parse import urlencode, urljoin, urlparse from xml.sax.saxutils import escape -import schoolopy + import requests import requests_oauthlib -from datetime import datetime, timedelta, timezone -from urllib.parse import urlencode, urljoin, urlparse +import schoolopy + from .convex_sync import ( - sync_courses, + clear_cache, sync_course_assignments, + sync_courses, sync_profile_picture, - clear_cache, ) # SSRF guard settings for outbound binary downloads (attachment URLs come from diff --git a/backend/services/schoology/refresh.py b/backend/services/schoology/refresh.py index 2407f16..37d34f6 100644 --- a/backend/services/schoology/refresh.py +++ b/backend/services/schoology/refresh.py @@ -2,13 +2,14 @@ Schoology refresh orchestration. """ import logging -from pathlib import Path import secrets import subprocess import sys +from pathlib import Path from config import Config from db.job_leases import acquire_schoology_refresh_lease, utcnow + from .runtime import create_schoology_service logger = logging.getLogger(__name__) diff --git a/backend/services/schoology/worker.py b/backend/services/schoology/worker.py index f08fed3..4bcc095 100644 --- a/backend/services/schoology/worker.py +++ b/backend/services/schoology/worker.py @@ -5,6 +5,7 @@ import sys from db.job_leases import release_schoology_refresh_lease + from .refresh import run_schoology_refresh_for_user logger = logging.getLogger(__name__) diff --git a/backend/services/scraper/attachment_download.py b/backend/services/scraper/attachment_download.py index 1d2129e..3f08579 100644 --- a/backend/services/scraper/attachment_download.py +++ b/backend/services/scraper/attachment_download.py @@ -4,16 +4,16 @@ from __future__ import annotations import hashlib -from pathlib import Path import re +from pathlib import Path from typing import Any from urllib.parse import urlparse from config import Config from services.schoology.client import SchoologyService -from .extraction import extract_attachment_text_if_needed from . import store +from .extraction import extract_attachment_text_if_needed from .google_drive import download_google_drive_link_if_needed, google_drive_url_info diff --git a/backend/services/scraper/extraction.py b/backend/services/scraper/extraction.py index 3c5929e..9c97f58 100644 --- a/backend/services/scraper/extraction.py +++ b/backend/services/scraper/extraction.py @@ -3,9 +3,9 @@ """ from __future__ import annotations -from functools import lru_cache import json import logging +from functools import lru_cache from pathlib import Path from typing import Any diff --git a/backend/services/scraper/scheduler.py b/backend/services/scraper/scheduler.py index 736a0be..d8185d5 100644 --- a/backend/services/scraper/scheduler.py +++ b/backend/services/scraper/scheduler.py @@ -4,11 +4,11 @@ from __future__ import annotations import logging -from pathlib import Path import secrets import subprocess import sys import time +from pathlib import Path from typing import Any from config import Config diff --git a/backend/services/scraper/section_sync.py b/backend/services/scraper/section_sync.py index 8fd98a0..86df186 100644 --- a/backend/services/scraper/section_sync.py +++ b/backend/services/scraper/section_sync.py @@ -3,9 +3,9 @@ """ from __future__ import annotations -from datetime import datetime import hashlib import logging +from datetime import datetime from pathlib import Path from typing import Any from urllib.parse import urlparse diff --git a/backend/services/scraper/store.py b/backend/services/scraper/store.py index cc8b66e..95a0e19 100644 --- a/backend/services/scraper/store.py +++ b/backend/services/scraper/store.py @@ -3,17 +3,16 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone import hashlib import json import logging import re +from datetime import datetime, timedelta, timezone from typing import Any from config import Config from db.pool import get_conn - logger = logging.getLogger(__name__) diff --git a/backend/tests/test_encryption.py b/backend/tests/test_encryption.py index 385d14f..eb458c8 100644 --- a/backend/tests/test_encryption.py +++ b/backend/tests/test_encryption.py @@ -5,7 +5,7 @@ import pytest from cryptography.fernet import Fernet, InvalidToken -from db.encryption import encrypt_token, decrypt_token +from db.encryption import decrypt_token, encrypt_token from db.init import hash_schoology_request_token diff --git a/backend/tests/test_jwt_utils.py b/backend/tests/test_jwt_utils.py index 464ab29..9ace237 100644 --- a/backend/tests/test_jwt_utils.py +++ b/backend/tests/test_jwt_utils.py @@ -9,12 +9,11 @@ import json from datetime import datetime, timedelta, timezone -import pytest import jwt as pyjwt +import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from config import Config from auth import jwt_utils from auth.jwt_utils import ( JWT_ALGORITHM, @@ -31,6 +30,7 @@ verify_mobile_access_token, verify_token, ) +from config import Config from tests.conftest import TEST_PRIVATE_KEY_PEM, TEST_PUBLIC_KEY_PEM USER = dict(user_id=42, email="student@pinewood.edu", name="Test Student") diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py new file mode 100644 index 0000000..135e577 --- /dev/null +++ b/backend/tests/test_middleware.py @@ -0,0 +1,72 @@ +""" +Tests for auth/middleware.py — the @auth_required decorator. + +Security properties under test: + - missing session cookie -> 401 + - invalid/expired session -> 401 (and the stale session_id is cleared) + - valid session -> handler runs, resolved user injected as kwargs["user"] + +We build a minimal Flask app and monkeypatch the session lookup that the +decorator imported into its own namespace (auth.middleware.get_session). +""" +import flask +import pytest + +from auth.middleware import auth_required + + +@pytest.fixture() +def app(): + application = flask.Flask(__name__) + application.secret_key = "test-secret" + + @application.route("/protected") + @auth_required + def protected(user): + return flask.jsonify({"user": user}) + + @application.route("/login-fake") + def login_fake(): + # Helper route to seat a session_id cookie for the "valid" path. + flask.session["session_id"] = "valid-session-id" + return "ok" + + return application + + +@pytest.fixture() +def client(app): + return app.test_client() + + +def test_no_session_returns_401(client): + resp = client.get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["error"] == "Authentication required" + + +def test_invalid_session_returns_401(client, monkeypatch): + # Seat a session_id cookie... + client.get("/login-fake") + # ...but the lookup resolves to no user (expired/forged session). + monkeypatch.setattr("auth.middleware.get_session", lambda sid: None) + resp = client.get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["error"] == "Invalid session" + + +def test_valid_session_injects_user(client, monkeypatch): + fake_user = {"id": 42, "email": "student@example.com", "name": "Test Student"} + called_with = {} + + def fake_get_session(sid): + called_with["sid"] = sid + return fake_user + + monkeypatch.setattr("auth.middleware.get_session", fake_get_session) + + client.get("/login-fake") + resp = client.get("/protected") + assert resp.status_code == 200 + assert resp.get_json()["user"] == fake_user + assert called_with["sid"] == "valid-session-id" diff --git a/backend/tests/test_scraper_section_leases.py b/backend/tests/test_scraper_section_leases.py index 562c7e7..7822d81 100644 --- a/backend/tests/test_scraper_section_leases.py +++ b/backend/tests/test_scraper_section_leases.py @@ -7,8 +7,8 @@ import pytest -from db.pool import get_conn from config import Config +from db.pool import get_conn from services.scraper.store import ( acquire_section_run, complete_section_run, diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 719cea2..6d22b8e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,16 +1,9 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import nextTypescript from "eslint-config-next/typescript"; const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + ...nextCoreWebVitals, + ...nextTypescript, { ignores: [ "node_modules/**", diff --git a/frontend/package.json b/frontend/package.json index 5dc14a0..ff7b766 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,11 +2,13 @@ "name": "chat", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "next dev --turbopack -p 3112", "build": "next build --turbopack", "start": "next start -p 3112", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "dependencies": { "@types/mdx": "^2.0.13",