FossilRepo

Sync all changes: public views, installer, infra, accounts rename, notifications, security, agent APIs

ragelink 2026-04-07 18:38 UTC trunk
Commit c5882553139e407ea2db2957c3c1ddd20b8f42b4c7d74cb0107b35340fbdefe6
241 files changed +16 +2 -3 +130 -24 +30 -6 +145 -51 +36 -11 -57 -84 +20 +7 +22 +92 +88 +308 +18 +287 -7 -22 -46 -10 -29 +30 -32 +11 -2 +36 -7 +1 -1 +5 +17 -2 +39 +42 +43 +6 -6 +135 +1 -1 +32 -28 +14 +194 +1 +40 +65 +8 +36 -29 -65 +178 +43 +38 +70 +1761 +39 +36 +188 -3 +58 +34 +1 -4 +213 +337 +389 +588 +430 +259 +609 +16 +13 -1 +57 -2 +7 -2 +99 +47 +17 -14 +2 -1 +129 -5 +163 -1 +48 +63 +99 -3 +28 +2624 -107 +56 +40 +2376 -12 -6 -18 -168 -22 -15 -133 -13 -86 +19 +10 +39 +673 +10 -3 +177 -2 +98 +193 +35 +13 +340 -7 +1 -1 +12 -3 +25 -10 +20 -5 +38 -2 +180 +50 +41 +8 -2 +8 +229 -9 +32 -5 +33 -16 +36 +111 +66 +48 +76 +44 +18 -36 +2 +99 -1 +52 +45 +16 +41 +56 +19 -1 +64 +50 +140 -9 +6 -6 +118 -15 +2 -2 +2 -2 +192 +103 +25 +48 -1 +20 +74 -77 +43 +55 +37 -9 +112 +62 +41 +5 +90 +100 -10 +19 -1 +46 +21 +41 -7 +38 +87 +43 +38 +61 +56 +24 +40 +57 +64 +47 +18 -4 +1 +27 +41 +2 -1 +118 -96 -28 -70 -42 -29 -44 +39 +15 -6 +11 -3 +49 +81 +98 +72 +14 +1 +108 +57 +41 +1 -1 +3 -2 +51 +28 +78 +50 +28 +27 +19 +1 +40 +1 +51 -21 +17 +463 +291 +121 +256 +235 +125 +349 +243 +290 +381 +247 +382 +1044 +710 +244 +347 +318 +301 +659 +14 +226 +164 +181 +383 +237 +324 +185 +339 +326 +121
+ CHANGELOG.md ~ CLAUDE.md ~ CONTRIBUTING.md ~ Dockerfile ~ README.md ~ SECURITY.md - _old_CLAUDE.md - _old_bootstrap.md + accounts/__init__.py + accounts/admin.py + accounts/apps.py + accounts/forms.py + accounts/migrations/0001_initial.py + accounts/migrations/__init__.py + accounts/models.py + accounts/tests.py + accounts/urls.py + accounts/views.py - auth1/__init__.py - auth1/apps.py - auth1/forms.py - auth1/migrations/__init__.py - auth1/tests.py - auth1/urls.py - auth1/views.py ~ bootstrap.md ~ config/settings.py ~ config/urls.py ~ conftest.py ~ core/admin.py ~ core/context_processors.py + core/decorators.py + core/fields.py + core/pagination.py ~ core/permissions.py + core/sanitize.py ~ core/templatetags/permissions_tags.py ~ core/tests.py ~ core/views.py ~ ctl/main.py ~ docker-compose.yaml + docker/entrypoint.sh + docker/fossil-shell + docker/litestream-ecs.yml + docker/sshd_config - fossil-platform/Dockerfile - fossil-platform/README.md ~ fossil/admin.py + fossil/agent_claims.py + fossil/api_auth.py + fossil/api_tokens.py + fossil/api_views.py + fossil/branch_protection.py + fossil/ci.py ~ fossil/cli.py + fossil/code_reviews.py + fossil/forum.py ~ fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py + fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py + fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py + fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py + fossil/migrations/0008_apitoken_historicalapitoken_and_more.py + fossil/migrations/0009_historicalticketfielddefinition_and_more.py + fossil/migrations/0010_historicalagentworkspace_agentworkspace.py + fossil/migrations/0011_codereview_historicalcodereview_and_more.py + fossil/migrations/0012_alter_ticketclaim_unique_together.py ~ fossil/models.py ~ fossil/notifications.py ~ fossil/oauth.py ~ fossil/reader.py + fossil/releases.py ~ fossil/signals.py ~ fossil/sync_models.py ~ fossil/tasks.py ~ fossil/tests.py + fossil/ticket_fields.py + fossil/ticket_reports.py ~ fossil/urls.py + fossil/user_keys.py ~ fossil/views.py + fossil/webhooks.py + fossil/workspaces.py + install.sh - items/__init__.py - items/admin.py - items/apps.py - items/forms.py - items/migrations/0001_initial.py - items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py - items/migrations/__init__.py - items/models.py - items/tests.py - items/urls.py - items/views.py + mcp_server/__init__.py + mcp_server/__main__.py + mcp_server/config.json + mcp_server/server.py + mcp_server/tools.py ~ organization/admin.py ~ organization/forms.py + organization/management/__init__.py + organization/management/commands/__init__.py + organization/management/commands/seed_roles.py + organization/migrations/0003_historicalorgrole_orgrole_and_more.py ~ organization/models.py ~ organization/urls.py ~ organization/views.py ~ pages/admin.py ~ pages/tests.py ~ pages/views.py ~ projects/admin.py ~ projects/forms.py + projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py + projects/migrations/0003_add_projectstar.py ~ projects/models.py ~ projects/tests.py ~ projects/urls.py ~ projects/views.py ~ pyproject.toml ~ templates/500.html + templates/accounts/login.html + templates/accounts/notification_prefs.html + templates/accounts/profile.html + templates/accounts/profile_edit.html + templates/accounts/profile_token_create.html + templates/accounts/profile_token_created.html + templates/accounts/ssh_keys.html - templates/auth1/login.html ~ templates/base.html ~ templates/dashboard.html + templates/email/digest.html + templates/email/notification.html ~ templates/fossil/_project_nav.html + templates/fossil/api_token_create.html + templates/fossil/api_token_list.html ~ templates/fossil/branch_list.html + templates/fossil/branch_protection_form.html + templates/fossil/branch_protection_list.html ~ templates/fossil/checkin_detail.html ~ templates/fossil/code_blame.html ~ templates/fossil/compare.html ~ templates/fossil/doc_page.html ~ templates/fossil/docs_index.html + templates/fossil/explorer.html + templates/fossil/explorer_query.html + templates/fossil/forum_form.html ~ templates/fossil/forum_list.html ~ templates/fossil/forum_thread.html ~ templates/fossil/git_mirror.html + templates/fossil/git_mirror_delete.html + templates/fossil/partials/explorer_table.html ~ templates/fossil/partials/timeline_entries.html + templates/fossil/release_detail.html + templates/fossil/release_form.html + templates/fossil/release_list.html + templates/fossil/repo_settings.html + templates/fossil/shun_list.html ~ templates/fossil/sync.html ~ templates/fossil/tag_list.html + templates/fossil/technote_detail.html + templates/fossil/technote_form.html ~ templates/fossil/technote_list.html ~ templates/fossil/ticket_edit.html + templates/fossil/ticket_fields_form.html + templates/fossil/ticket_fields_list.html ~ templates/fossil/ticket_form.html + templates/fossil/ticket_report_form.html + templates/fossil/ticket_report_results.html + templates/fossil/ticket_reports_list.html + templates/fossil/unversioned_list.html + templates/fossil/webhook_deliveries.html + templates/fossil/webhook_form.html + templates/fossil/webhook_list.html ~ templates/fossil/wiki_list.html + templates/includes/_pagination.html + templates/includes/_pagination_manual.html + templates/includes/_sidebar_project.html ~ templates/includes/nav.html ~ templates/includes/sidebar.html - templates/items/item_confirm_delete.html - templates/items/item_detail.html - templates/items/item_form.html - templates/items/item_list.html - templates/items/partials/item_table.html + templates/organization/audit_log.html ~ templates/organization/member_list.html ~ templates/organization/partials/member_table.html + templates/organization/role_confirm_delete.html + templates/organization/role_detail.html + templates/organization/role_form.html + templates/organization/role_list.html ~ templates/organization/settings.html ~ templates/organization/team_list.html + templates/organization/user_detail.html + templates/organization/user_form.html + templates/organization/user_password.html ~ templates/pages/page_form.html ~ templates/pages/page_list.html + templates/projects/explore.html + templates/projects/group_confirm_delete.html + templates/projects/group_detail.html + templates/projects/group_form.html + templates/projects/group_list.html + templates/projects/partials/group_table.html + templates/projects/partials/star_button.html ~ templates/projects/project_detail.html ~ templates/projects/project_form.html ~ templates/projects/project_list.html ~ testdata/management/commands/seed.py + tests/test_agent_coordination.py + tests/test_anonymous_access.py + tests/test_api_tokens.py + tests/test_audit_log.py + tests/test_branch_protection.py + tests/test_branch_protection_enforcement.py + tests/test_bundle_cli.py + tests/test_ci_status.py + tests/test_dashboard_heatmap.py + tests/test_email_templates.py + tests/test_explorer.py + tests/test_forum.py + tests/test_git_mirrors.py + tests/test_json_api.py + tests/test_mcp_server.py + tests/test_notification_prefs.py + tests/test_project_groups.py + tests/test_releases.py + tests/test_repo_lifecycle.py + tests/test_roles.py + tests/test_security.py + tests/test_shunning.py + tests/test_split_diff.py + tests/test_starring.py + tests/test_technotes.py + tests/test_ticket_fields.py + tests/test_ticket_reports.py + tests/test_unversioned.py + tests/test_user_management.py + tests/test_webhooks.py ~ uv.lock
+16
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
1
+# Changelog
2
+
3
+All notable changes to Fossilrepo are documented here.
4
+
5
+## [0.1.0] - 2026-04-07
6
+
7
+Initial open source release.
8
+
9
+### Features
10
+
11
+- **Code browser** with directory navigation, syntax highlighting, line numbers, permalinks, blame with age coloring, file history, raw download
12
+- **Timeline** with DAG graph (fork/merge connectors, color-coded branches, merge diamonds, leaf indicators), keyboard navigation, HTMX infinite scroll, RSS feed
13
+- **Diffs** with unified and side-by-side views, syntax highlighting via highlight.js, line-level permalinks
14
+- **Tickets** with full CRUD (create, edit, close/reopen, comment), filters, pagination, CSV export
15
+- **Wiki** with Markdown + Fossil markup + Pikchr diagrams, create/edit, right-sidebar TOC
16
+- **Forum** with threaded discussions, create th
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
1 # Changelog
2
3 All notable changes to Fossilrepo are documented here.
4
5 ## [0.1.0] - 2026-04-07
6
7 Initial open source release.
8
9 ### Features
10
11 - **Code browser** with directory navigation, syntax highlighting, line numbers, permalinks, blame with age coloring, file history, raw download
12 - **Timeline** with DAG graph (fork/merge connectors, color-coded branches, merge diamonds, leaf indicators), keyboard navigation, HTMX infinite scroll, RSS feed
13 - **Diffs** with unified and side-by-side views, syntax highlighting via highlight.js, line-level permalinks
14 - **Tickets** with full CRUD (create, edit, close/reopen, comment), filters, pagination, CSV export
15 - **Wiki** with Markdown + Fossil markup + Pikchr diagrams, create/edit, right-sidebar TOC
16 - **Forum** with threaded discussions, create th
+2 -3
--- CLAUDE.md
+++ CLAUDE.md
@@ -26,13 +26,12 @@
2626
## Repository Structure
2727
2828
```
2929
fossilrepo/
3030
├── core/ # Base models, permissions, shared utilities
31
-├── auth1/ # Authentication
31
+├── accounts/ # Authentication
3232
├── organization/ # Org/team management
33
-├── items/ # Repo item models
3433
├── config/ # Django settings
3534
├── templates/ # Django + HTMX templates
3635
├── static/ # Static assets
3736
├── docker/ # Caddy, Litestream container configs
3837
├── fossil-platform/ # Old exploration (Flask + React), kept for reference
@@ -45,10 +44,10 @@
4544
4645
- Prefer `Edit` over rewriting whole files.
4746
- Run `ruff check .` and `ruff format --check .` before committing.
4847
- Never expose integer PKs in URLs or templates -- use `slug` or `guid`.
4948
- Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`.
50
-- Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`.
49
+- Soft-delete only: call `obj.soft_delete(user=request.user)`, never `.delete()`.
5150
- HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
5251
- CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
5352
- Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
5453
- Fossil is the source of truth; Git remotes are downstream mirrors.
5554
--- CLAUDE.md
+++ CLAUDE.md
@@ -26,13 +26,12 @@
26 ## Repository Structure
27
28 ```
29 fossilrepo/
30 ├── core/ # Base models, permissions, shared utilities
31 ├── auth1/ # Authentication
32 ├── organization/ # Org/team management
33 ├── items/ # Repo item models
34 ├── config/ # Django settings
35 ├── templates/ # Django + HTMX templates
36 ├── static/ # Static assets
37 ├── docker/ # Caddy, Litestream container configs
38 ├── fossil-platform/ # Old exploration (Flask + React), kept for reference
@@ -45,10 +44,10 @@
45
46 - Prefer `Edit` over rewriting whole files.
47 - Run `ruff check .` and `ruff format --check .` before committing.
48 - Never expose integer PKs in URLs or templates -- use `slug` or `guid`.
49 - Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`.
50 - Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`.
51 - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
52 - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
53 - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
54 - Fossil is the source of truth; Git remotes are downstream mirrors.
55
--- CLAUDE.md
+++ CLAUDE.md
@@ -26,13 +26,12 @@
26 ## Repository Structure
27
28 ```
29 fossilrepo/
30 ├── core/ # Base models, permissions, shared utilities
31 ├── accounts/ # Authentication
32 ├── organization/ # Org/team management
 
33 ├── config/ # Django settings
34 ├── templates/ # Django + HTMX templates
35 ├── static/ # Static assets
36 ├── docker/ # Caddy, Litestream container configs
37 ├── fossil-platform/ # Old exploration (Flask + React), kept for reference
@@ -45,10 +44,10 @@
44
45 - Prefer `Edit` over rewriting whole files.
46 - Run `ruff check .` and `ruff format --check .` before committing.
47 - Never expose integer PKs in URLs or templates -- use `slug` or `guid`.
48 - Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`.
49 - Soft-delete only: call `obj.soft_delete(user=request.user)`, never `.delete()`.
50 - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
51 - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
52 - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
53 - Fossil is the source of truth; Git remotes are downstream mirrors.
54
+130 -24
--- CONTRIBUTING.md
+++ CONTRIBUTING.md
@@ -1,32 +1,138 @@
1
-# Contributing to Fossilrepo django + htmx
2
-
3
-Thank you for your interest in contributing!
4
-
5
-## Getting Started
6
-
7
-1. Fork the repository
8
-2. Clone your fork
9
-3. Run `docker compose up -d` (or see README.md for stack-specific setup)
10
-4. Create a feature branch from `main`
11
-
12
-## Development Process
13
-
14
-1. Pick an issue from the project board
15
-2. Comment your plan on the issue before starting
16
-3. Create a branch: `feature/issue-number-description` or `fix/issue-number-description`
17
-4. Make your changes following `bootstrap.md` conventions
18
-5. Write or update tests
19
-6. Run lint and tests (see README.md for commands)
20
-7. Submit a pull request
1
+# Contributing to Fossilrepo
2
+
3
+Thanks for your interest in contributing. This document covers how to get set up, our coding standards, and the PR process.
4
+
5
+## Development Setup
6
+
7
+### Prerequisites
8
+
9
+- Python 3.12+
10
+- Docker and Docker Compose
11
+- [uv](https://docs.astral.sh/uv/) (Python package manager)
12
+- [Ruff](https://docs.astral.sh/ruff/) (linter/formatter)
13
+
14
+### Running Locally
15
+
16
+```bash
17
+git clone https://github.com/ConflictHQ/fossilrepo.git
18
+cd fossilrepo
19
+
20
+# Start infrastructure
21
+docker compose up -d postgres redis mailpit
22
+
23
+# Install dependencies
24
+uv sync --all-extras
25
+
26
+# Run migrations and seed data
27
+DJANGO_DEBUG=true uv run python manage.py migrate
28
+DJANGO_DEBUG=true uv run python manage.py seed
29
+
30
+# Start the dev server
31
+DJANGO_DEBUG=true POSTGRES_HOST=localhost uv run python manage.py runserver
32
+```
33
+
34
+Or use Docker for everything:
35
+
36
+```bash
37
+docker compose up -d --build
38
+docker compose exec backend python manage.py migrate
39
+docker compose exec backend python manage.py seed
40
+```
41
+
42
+### Default Users
43
+
44
+- `admin` / `admin` — superuser, full access
45
+- `viewer` / `viewer` — read-only permissions
2146
2247
## Code Style
2348
24
-See `bootstrap.md` for conventions. Run the linter before committing.
49
+We use **Ruff** for linting and formatting. No debates, no custom configs.
50
+
51
+```bash
52
+# Check
53
+ruff check .
54
+ruff format --check .
55
+
56
+# Fix
57
+ruff check --fix .
58
+ruff format .
59
+```
60
+
61
+Key conventions:
62
+
63
+- **Max line length:** 140 characters
64
+- **Imports:** sorted by Ruff (isort rules)
65
+- **Quote style:** double quotes
66
+- **Target:** Python 3.12+
67
+
68
+## Codebase Conventions
69
+
70
+Read [`bootstrap.md`](bootstrap.md) before writing code. It covers:
71
+
72
+- Model base classes (`Tracking`, `BaseCoreModel`)
73
+- Soft deletes (never call `.delete()`)
74
+- Permission system (`P` enum + project-level RBAC)
75
+- View patterns (HTMX partials, auth checks)
76
+- Template conventions (dark theme, Tailwind classes)
2577
2678
## Testing
2779
28
-All new features need tests. All bug fixes need regression tests. Tests must use a real database — never mock.
80
+Tests run against a real PostgreSQL database. No mocked databases.
81
+
82
+```bash
83
+# Run all tests
84
+DJANGO_DEBUG=true uv run pytest
85
+
86
+# Run specific test file
87
+DJANGO_DEBUG=true uv run pytest tests/test_releases.py
88
+
89
+# Run with coverage
90
+DJANGO_DEBUG=true uv run pytest --cov
91
+```
92
+
93
+Every PR should:
94
+
95
+- Include tests for new features (happy path + permission denied cases)
96
+- Not decrease test coverage
97
+- Pass all existing tests
98
+
99
+## Pull Request Process
100
+
101
+1. **Fork and branch** from `main`. Branch naming: `feature/short-description` or `fix/short-description`.
102
+
103
+2. **Write code** following the conventions in `bootstrap.md`.
104
+
105
+3. **Write tests.** Both allowed and denied permission cases. Assert against database state, not just status codes.
106
+
107
+4. **Lint and test locally.** CI will catch it anyway, but save yourself a round trip.
108
+
109
+5. **Open a PR** with a clear description:
110
+ - What changed and why
111
+ - How to test it
112
+ - Link to any related issues
113
+
114
+6. **Address review feedback** in new commits (don't amend/squash during review).
115
+
116
+7. **Merge** when CI is green and review is approved.
117
+
118
+## Reporting Issues
119
+
120
+Use [GitHub Issues](https://github.com/ConflictHQ/fossilrepo/issues). Include:
121
+
122
+- What you expected to happen
123
+- What actually happened
124
+- Steps to reproduce
125
+- Browser/OS/version if relevant
126
+
127
+## Architecture Decisions
128
+
129
+Fossilrepo has some non-obvious design choices worth understanding:
130
+
131
+- **No Fossil HTTP server.** We read `.fossil` files directly via SQLite (`FossilReader`) and use `fossil http` in CGI mode for sync. No persistent Fossil process, stateless containers.
132
+- **Django-backed forum posts** supplement Fossil's native forum because Fossil forum posts don't sync via clone/pull.
133
+- **Encrypted fields** use Fernet (AES-128-CBC + HMAC) keyed from `SECRET_KEY` for SSH keys and OAuth tokens at rest.
134
+- **Single org model.** Multi-org is possible but not implemented — fossilrepo targets self-hosted single-team deployments.
29135
30
-## Questions?
136
+## License
31137
32
-Open an issue or start a discussion in this repository.
138
+By contributing, you agree that your contributions will be licensed under the MIT License.
33139
--- CONTRIBUTING.md
+++ CONTRIBUTING.md
@@ -1,32 +1,138 @@
1 # Contributing to Fossilrepo django + htmx
2
3 Thank you for your interest in contributing!
4
5 ## Getting Started
6
7 1. Fork the repository
8 2. Clone your fork
9 3. Run `docker compose up -d` (or see README.md for stack-specific setup)
10 4. Create a feature branch from `main`
11
12 ## Development Process
13
14 1. Pick an issue from the project board
15 2. Comment your plan on the issue before starting
16 3. Create a branch: `feature/issue-number-description` or `fix/issue-number-description`
17 4. Make your changes following `bootstrap.md` conventions
18 5. Write or update tests
19 6. Run lint and tests (see README.md for commands)
20 7. Submit a pull request
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
22 ## Code Style
23
24 See `bootstrap.md` for conventions. Run the linter before committing.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
26 ## Testing
27
28 All new features need tests. All bug fixes need regression tests. Tests must use a real database — never mock.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
30 ## Questions?
31
32 Open an issue or start a discussion in this repository.
33
--- CONTRIBUTING.md
+++ CONTRIBUTING.md
@@ -1,32 +1,138 @@
1 # Contributing to Fossilrepo
2
3 Thanks for your interest in contributing. This document covers how to get set up, our coding standards, and the PR process.
4
5 ## Development Setup
6
7 ### Prerequisites
8
9 - Python 3.12+
10 - Docker and Docker Compose
11 - [uv](https://docs.astral.sh/uv/) (Python package manager)
12 - [Ruff](https://docs.astral.sh/ruff/) (linter/formatter)
13
14 ### Running Locally
15
16 ```bash
17 git clone https://github.com/ConflictHQ/fossilrepo.git
18 cd fossilrepo
19
20 # Start infrastructure
21 docker compose up -d postgres redis mailpit
22
23 # Install dependencies
24 uv sync --all-extras
25
26 # Run migrations and seed data
27 DJANGO_DEBUG=true uv run python manage.py migrate
28 DJANGO_DEBUG=true uv run python manage.py seed
29
30 # Start the dev server
31 DJANGO_DEBUG=true POSTGRES_HOST=localhost uv run python manage.py runserver
32 ```
33
34 Or use Docker for everything:
35
36 ```bash
37 docker compose up -d --build
38 docker compose exec backend python manage.py migrate
39 docker compose exec backend python manage.py seed
40 ```
41
42 ### Default Users
43
44 - `admin` / `admin` — superuser, full access
45 - `viewer` / `viewer` — read-only permissions
46
47 ## Code Style
48
49 We use **Ruff** for linting and formatting. No debates, no custom configs.
50
51 ```bash
52 # Check
53 ruff check .
54 ruff format --check .
55
56 # Fix
57 ruff check --fix .
58 ruff format .
59 ```
60
61 Key conventions:
62
63 - **Max line length:** 140 characters
64 - **Imports:** sorted by Ruff (isort rules)
65 - **Quote style:** double quotes
66 - **Target:** Python 3.12+
67
68 ## Codebase Conventions
69
70 Read [`bootstrap.md`](bootstrap.md) before writing code. It covers:
71
72 - Model base classes (`Tracking`, `BaseCoreModel`)
73 - Soft deletes (never call `.delete()`)
74 - Permission system (`P` enum + project-level RBAC)
75 - View patterns (HTMX partials, auth checks)
76 - Template conventions (dark theme, Tailwind classes)
77
78 ## Testing
79
80 Tests run against a real PostgreSQL database. No mocked databases.
81
82 ```bash
83 # Run all tests
84 DJANGO_DEBUG=true uv run pytest
85
86 # Run specific test file
87 DJANGO_DEBUG=true uv run pytest tests/test_releases.py
88
89 # Run with coverage
90 DJANGO_DEBUG=true uv run pytest --cov
91 ```
92
93 Every PR should:
94
95 - Include tests for new features (happy path + permission denied cases)
96 - Not decrease test coverage
97 - Pass all existing tests
98
99 ## Pull Request Process
100
101 1. **Fork and branch** from `main`. Branch naming: `feature/short-description` or `fix/short-description`.
102
103 2. **Write code** following the conventions in `bootstrap.md`.
104
105 3. **Write tests.** Both allowed and denied permission cases. Assert against database state, not just status codes.
106
107 4. **Lint and test locally.** CI will catch it anyway, but save yourself a round trip.
108
109 5. **Open a PR** with a clear description:
110 - What changed and why
111 - How to test it
112 - Link to any related issues
113
114 6. **Address review feedback** in new commits (don't amend/squash during review).
115
116 7. **Merge** when CI is green and review is approved.
117
118 ## Reporting Issues
119
120 Use [GitHub Issues](https://github.com/ConflictHQ/fossilrepo/issues). Include:
121
122 - What you expected to happen
123 - What actually happened
124 - Steps to reproduce
125 - Browser/OS/version if relevant
126
127 ## Architecture Decisions
128
129 Fossilrepo has some non-obvious design choices worth understanding:
130
131 - **No Fossil HTTP server.** We read `.fossil` files directly via SQLite (`FossilReader`) and use `fossil http` in CGI mode for sync. No persistent Fossil process, stateless containers.
132 - **Django-backed forum posts** supplement Fossil's native forum because Fossil forum posts don't sync via clone/pull.
133 - **Encrypted fields** use Fernet (AES-128-CBC + HMAC) keyed from `SECRET_KEY` for SSH keys and OAuth tokens at rest.
134 - **Single org model.** Multi-org is possible but not implemented — fossilrepo targets self-hosted single-team deployments.
135
136 ## License
137
138 By contributing, you agree that your contributions will be licensed under the MIT License.
139
+30 -6
--- Dockerfile
+++ Dockerfile
@@ -24,11 +24,11 @@
2424
# ── Stage 2: Runtime image ─────────────────────────────────────────────────
2525
2626
FROM python:3.12-slim-bookworm
2727
2828
RUN apt-get update && apt-get install -y --no-install-recommends \
29
- postgresql-client ca-certificates zlib1g libssl3 \
29
+ postgresql-client ca-certificates zlib1g libssl3 openssh-server git \
3030
&& rm -rf /var/lib/apt/lists/*
3131
3232
# Copy Fossil binary from builder
3333
COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
3434
RUN fossil version
@@ -40,17 +40,41 @@
4040
COPY pyproject.toml ./
4141
RUN uv pip install --system --no-cache -r pyproject.toml
4242
4343
COPY . .
4444
45
-RUN python manage.py collectstatic --noinput 2>/dev/null || true
45
+RUN DJANGO_SECRET_KEY=build-placeholder DJANGO_DEBUG=true python manage.py collectstatic --noinput
46
+
47
+# Create data directories
48
+RUN mkdir -p /data/repos /data/trash /data/ssh
49
+
50
+# SSH setup — restricted fossil user + sshd for clone/push
51
+RUN useradd -r -m -d /home/fossil -s /usr/sbin/nologin fossil \
52
+ && mkdir -p /run/sshd /home/fossil/.ssh \
53
+ && chown fossil:fossil /home/fossil/.ssh \
54
+ && chmod 700 /home/fossil/.ssh
55
+
56
+COPY docker/sshd_config /etc/ssh/sshd_config
57
+COPY docker/fossil-shell /usr/local/bin/fossil-shell
58
+RUN chmod +x /usr/local/bin/fossil-shell
59
+
60
+# Generate host keys if they don't exist (entrypoint will handle persistent keys)
61
+RUN ssh-keygen -A
4662
47
-# Create data directory for .fossil files
48
-RUN mkdir -p /data/repos /data/trash
63
+# Create non-root app user for running gunicorn
64
+RUN useradd -r -m -d /home/app -s /bin/false app \
65
+ && chown -R app:app /app /data
4966
5067
ENV PYTHONUNBUFFERED=1
5168
ENV PYTHONDONTWRITEBYTECODE=1
5269
ENV DJANGO_SETTINGS_MODULE=config.settings
5370
54
-EXPOSE 8000
71
+EXPOSE 8000 2222
72
+
73
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
74
+RUN chmod +x /usr/local/bin/entrypoint.sh
75
+
76
+# Install gosu for privilege dropping in entrypoint
77
+RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
5578
56
-CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
79
+# Entrypoint runs as root (to start sshd), then drops to app user for gunicorn
80
+CMD ["/usr/local/bin/entrypoint.sh"]
5781
--- Dockerfile
+++ Dockerfile
@@ -24,11 +24,11 @@
24 # ── Stage 2: Runtime image ─────────────────────────────────────────────────
25
26 FROM python:3.12-slim-bookworm
27
28 RUN apt-get update && apt-get install -y --no-install-recommends \
29 postgresql-client ca-certificates zlib1g libssl3 \
30 && rm -rf /var/lib/apt/lists/*
31
32 # Copy Fossil binary from builder
33 COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
34 RUN fossil version
@@ -40,17 +40,41 @@
40 COPY pyproject.toml ./
41 RUN uv pip install --system --no-cache -r pyproject.toml
42
43 COPY . .
44
45 RUN python manage.py collectstatic --noinput 2>/dev/null || true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
47 # Create data directory for .fossil files
48 RUN mkdir -p /data/repos /data/trash
 
49
50 ENV PYTHONUNBUFFERED=1
51 ENV PYTHONDONTWRITEBYTECODE=1
52 ENV DJANGO_SETTINGS_MODULE=config.settings
53
54 EXPOSE 8000
 
 
 
 
 
 
55
56 CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
 
57
--- Dockerfile
+++ Dockerfile
@@ -24,11 +24,11 @@
24 # ── Stage 2: Runtime image ─────────────────────────────────────────────────
25
26 FROM python:3.12-slim-bookworm
27
28 RUN apt-get update && apt-get install -y --no-install-recommends \
29 postgresql-client ca-certificates zlib1g libssl3 openssh-server git \
30 && rm -rf /var/lib/apt/lists/*
31
32 # Copy Fossil binary from builder
33 COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
34 RUN fossil version
@@ -40,17 +40,41 @@
40 COPY pyproject.toml ./
41 RUN uv pip install --system --no-cache -r pyproject.toml
42
43 COPY . .
44
45 RUN DJANGO_SECRET_KEY=build-placeholder DJANGO_DEBUG=true python manage.py collectstatic --noinput
46
47 # Create data directories
48 RUN mkdir -p /data/repos /data/trash /data/ssh
49
50 # SSH setup — restricted fossil user + sshd for clone/push
51 RUN useradd -r -m -d /home/fossil -s /usr/sbin/nologin fossil \
52 && mkdir -p /run/sshd /home/fossil/.ssh \
53 && chown fossil:fossil /home/fossil/.ssh \
54 && chmod 700 /home/fossil/.ssh
55
56 COPY docker/sshd_config /etc/ssh/sshd_config
57 COPY docker/fossil-shell /usr/local/bin/fossil-shell
58 RUN chmod +x /usr/local/bin/fossil-shell
59
60 # Generate host keys if they don't exist (entrypoint will handle persistent keys)
61 RUN ssh-keygen -A
62
63 # Create non-root app user for running gunicorn
64 RUN useradd -r -m -d /home/app -s /bin/false app \
65 && chown -R app:app /app /data
66
67 ENV PYTHONUNBUFFERED=1
68 ENV PYTHONDONTWRITEBYTECODE=1
69 ENV DJANGO_SETTINGS_MODULE=config.settings
70
71 EXPOSE 8000 2222
72
73 COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
74 RUN chmod +x /usr/local/bin/entrypoint.sh
75
76 # Install gosu for privilege dropping in entrypoint
77 RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
78
79 # Entrypoint runs as root (to start sshd), then drops to app user for gunicorn
80 CMD ["/usr/local/bin/entrypoint.sh"]
81
+145 -51
--- README.md
+++ README.md
@@ -1,95 +1,189 @@
1
-# Fossilrepo Django + HTMX
1
+# Fossilrepo
2
+
3
+**Self-hosted Fossil forge with a modern web interface.**
4
+
5
+Fossilrepo wraps [Fossil SCM](https://fossil-scm.org) with a Django + HTMX management layer, replacing Fossil's built-in web UI with a GitHub/GitLab-caliber experience while preserving everything that makes Fossil unique: single-file repos, built-in wiki, tickets, forum, and technotes.
6
+
7
+## Why Fossilrepo?
8
+
9
+Fossil is the most underrated version control system. Every repository is a single SQLite file containing your code, wiki, tickets, forum, and technotes. No external services, no complex setup. But its web UI hasn't changed since 1998.
10
+
11
+Fossilrepo fixes that. You get:
12
+
13
+- A modern dark/light UI built with Django, HTMX, Alpine.js, and Tailwind CSS
14
+- GitHub-style code browser with line numbers, blame, history, and syntax highlighting
15
+- Timeline with DAG graph showing fork/merge connectors and color-coded branches
16
+- Full ticket CRUD with filters, comments, and CSV export
17
+- Wiki with Markdown + Fossil markup + Pikchr diagram rendering
18
+- Forum with threaded discussions
19
+- Releases with file attachments and markdown changelogs
20
+- Git mirror sync to GitHub/GitLab via OAuth
21
+- Clone/push/pull over HTTP and SSH through Django's auth layer
22
+- Webhook dispatch with HMAC signing and delivery logs
23
+- Omnibus Docker image with Fossil compiled from source
224
3
-Server-rendered Django with HTMX for dynamic behavior and Alpine.js for lightweight client state. Tailwind CSS for styling. Choose this for content-heavy CRUD, admin-centric tools, and apps where server-rendered simplicity beats a full SPA.
25
+All while Fossil remains the source of truth. Fossilrepo reads `.fossil` files directly via SQLite for speed, and uses the `fossil` CLI for writes to preserve artifact integrity.
426
527
## Stack
628
729
| Layer | Technology |
830
|-------|-----------|
931
| Backend | Django 5 (Python 3.12+) |
10
-| Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS |
11
-| Database | PostgreSQL 16 |
32
+| Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN) |
33
+| Database | PostgreSQL 16 (app data) + SQLite (Fossil repos) |
1234
| Cache/Broker | Redis 7 |
13
-| Job Queue | Celery + Redis |
35
+| Jobs | Celery + Redis |
1436
| Auth | Session-based (httpOnly cookies) |
37
+| SCM | Fossil 2.24 (compiled from source in Docker) |
1538
| Linter | Ruff |
16
-| Package Manager | uv |
1739
1840
## Quick Start
1941
2042
```bash
21
-# Start the stack
43
+git clone https://github.com/ConflictHQ/fossilrepo.git
44
+cd fossilrepo
2245
docker compose up -d --build
2346
24
-# Run migrations and seed data
47
+# Run migrations and seed sample data
2548
docker compose exec backend python manage.py migrate
2649
docker compose exec backend python manage.py seed
2750
2851
# Open the app
2952
open http://localhost:8000
3053
```
3154
32
-**Default users:**
33
-- `admin` / `admin` (superuser, full access)
34
-- `viewer` / `viewer` (view-only permissions)
55
+**Default users:** `admin` / `admin` (superuser) and `viewer` / `viewer` (read-only).
56
+
57
+## Features
58
+
59
+### Code Browser
60
+- Directory navigation with breadcrumbs
61
+- Syntax-highlighted source view with line numbers and permalinks
62
+- Blame with age-based coloring (newest = brand red, oldest = gray)
63
+- File history, raw download, rendered preview for Markdown/HTML
64
+
65
+### Timeline
66
+- DAG graph with fork/merge connectors, color-coded branches
67
+- Merge commit diamonds, leaf indicators
68
+- Keyboard navigation (j/k/Enter), HTMX infinite scroll
69
+- RSS feed
70
+
71
+### Diffs
72
+- Unified and side-by-side view (toggle with localStorage preference)
73
+- Syntax highlighting via highlight.js
74
+- Line-level permalinks
75
+- Compare any two checkins
76
+
77
+### Tickets
78
+- Filter by status, type, priority, severity
79
+- Full CRUD: create, edit, close/reopen, comment
80
+- CSV export
81
+- Pagination with configurable page size
82
+
83
+### Wiki
84
+- Markdown + Fossil wiki markup + raw HTML
85
+- Pikchr diagram rendering
86
+- Right-sidebar table of contents
87
+- Create and edit pages
88
+
89
+### Forum
90
+- Threaded discussions (Fossil-native + Django-backed posts)
91
+- Create threads, post replies
92
+- Markdown body with preview
93
+
94
+### Releases
95
+- Versioned releases with tag names and markdown changelogs
96
+- File attachments with download counts
97
+- Draft and prerelease support
98
+
99
+### Sync
100
+- Pull from upstream Fossil remotes
101
+- Git mirror to GitHub/GitLab (OAuth or SSH key auth)
102
+- Clone/push/pull over HTTP via `fossil http` CGI proxy
103
+- SSH push via restricted sshd (port 2222)
104
+- Configurable sync modes: on-change, scheduled, both
105
+
106
+### Webhooks
107
+- Outbound webhooks on checkin, ticket, wiki, and release events
108
+- HMAC-SHA256 signed payloads
109
+- Exponential backoff retry (3 attempts)
110
+- Delivery log with response status and timing
111
+
112
+### Organization
113
+- Single-org model with teams and members
114
+- User CRUD: create, edit, deactivate, change password
115
+- Team management with member assignment
116
+- Project-level team roles: read, write, admin
117
+- Project visibility: public, internal, private
118
+
119
+### Infrastructure
120
+- Omnibus Docker image (Fossil compiled from source)
121
+- Caddy for SSL termination and subdomain routing
122
+- Litestream for continuous SQLite-to-S3 replication
123
+- Celery Beat for scheduled metadata sync and upstream checks
124
+- Encrypted credential storage (Fernet/AES-128-CBC at rest)
35125
36126
## Architecture
37127
38128
```
39129
Browser
40
- +-- Django Templates + HTMX + Alpine.js + Tailwind CSS
41
- |
42
- v (standard HTTP + HTMX partial responses)
43
- |
44
- Django 5 (Views, ORM, Permissions)
45
- |-- Celery (async tasks)
46
- |-- PostgreSQL 16 (data)
47
- +-- Redis 7 (cache, sessions, broker)
130
+ |
131
+ v
132
+Django 5 + HTMX + Alpine.js + Tailwind CSS
133
+ |
134
+ |-- FossilReader (direct SQLite reads from .fossil files)
135
+ |-- FossilCLI (subprocess wrapper for write operations)
136
+ |-- fossil http (CGI proxy for clone/push/pull)
137
+ |
138
+ |-- PostgreSQL 16 (orgs, users, teams, projects, settings)
139
+ |-- Redis 7 (Celery broker, cache)
140
+ |-- Celery (background sync, webhooks, notifications)
141
+ |
142
+ v
143
+.fossil files (SQLite — code + wiki + tickets + forum + technotes)
144
+ |
145
+ v
146
+Litestream --> S3 (continuous backup)
48147
```
49148
50
-No separate frontend service. Django serves everything — templates, static files, and HTMX partials.
51
-
52
-## Endpoints
53
-
54
-| Path | Description |
55
-|------|------------|
56
-| `/` | Redirects to dashboard |
57
-| `/dashboard/` | Main dashboard |
58
-| `/items/` | Item list with HTMX search |
59
-| `/items/create/` | Create item form |
60
-| `/items/<slug>/` | Item detail |
61
-| `/items/<slug>/edit/` | Edit item form |
62
-| `/items/<slug>/delete/` | Delete confirmation |
63
-| `/auth/login/` | Login page |
64
-| `/auth/logout/` | Logout |
65
-| `/admin/` | Django admin |
66
-| `/health/` | Health check (JSON) |
149
+No separate frontend service. Django serves everything: templates, static files, and HTMX partials.
150
+
151
+## Configuration
152
+
153
+All runtime settings are configurable via Django admin (Constance):
154
+
155
+| Setting | Default | Description |
156
+|---------|---------|-------------|
157
+| `SITE_NAME` | Fossilrepo | Display name |
158
+| `FOSSIL_DATA_DIR` | /data/repos | Where .fossil files live |
159
+| `FOSSIL_BINARY_PATH` | fossil | Path to the fossil binary |
160
+| `FOSSIL_STORE_IN_DB` | false | Store .fossil snapshots via Django file storage |
161
+| `FOSSIL_S3_TRACKING` | false | Track S3/Litestream replication |
162
+| `GIT_SYNC_MODE` | disabled | Default sync mode for new mirrors |
163
+| `GIT_SYNC_SCHEDULE` | */15 * * * * | Default cron for scheduled sync |
164
+
165
+See [`.env.example`](.env.example) for all environment variables and [`.env.production.example`](.env.production.example) for production configuration.
67166
68167
## Development
69168
70169
```bash
71170
# Local development (without Docker)
72171
uv sync --all-extras
73
-POSTGRES_HOST=localhost POSTGRES_PORT=5434 uv run python manage.py runserver
172
+DJANGO_DEBUG=true POSTGRES_HOST=localhost uv run python manage.py runserver
74173
75174
# Run tests
76
-make test
175
+DJANGO_DEBUG=true uv run pytest
77176
78
-# Run linter
79
-make lint
177
+# Lint
178
+ruff check . && ruff format --check .
80179
```
81180
82
-## Conventions
83
-
84
-See [`bootstrap.md`](bootstrap.md) for the full conventions document.
85
-
86
-Key patterns:
87
-- All models inherit `Tracking` (audit trails) or `BaseCoreModel` (named entities with UUID)
88
-- Soft deletes only — never call `.delete()` on business objects
89
-- Group-based permissions checked in every view via `P.PERMISSION.check(user)`
90
-- HTMX partials for dynamic updates, Alpine.js for client-side state
91
-- Tests against real Postgres, both allowed and denied permission cases
181
+See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full development guide and [`bootstrap.md`](bootstrap.md) for codebase conventions.
182
+
183
+## License
184
+
185
+MIT License. See [LICENSE](LICENSE) for details.
92186
93187
---
94188
95
-Fossilrepo is a [CONFLICT](https://weareconflict.com) brand. CONFLICT is a registered trademark of CONFLICT LLC.
189
+Built by [CONFLICT](https://weareconflict.com). Fossilrepo is open source under the MIT license.
96190
--- README.md
+++ README.md
@@ -1,95 +1,189 @@
1 # Fossilrepo Django + HTMX
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 Server-rendered Django with HTMX for dynamic behavior and Alpine.js for lightweight client state. Tailwind CSS for styling. Choose this for content-heavy CRUD, admin-centric tools, and apps where server-rendered simplicity beats a full SPA.
4
5 ## Stack
6
7 | Layer | Technology |
8 |-------|-----------|
9 | Backend | Django 5 (Python 3.12+) |
10 | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS |
11 | Database | PostgreSQL 16 |
12 | Cache/Broker | Redis 7 |
13 | Job Queue | Celery + Redis |
14 | Auth | Session-based (httpOnly cookies) |
 
15 | Linter | Ruff |
16 | Package Manager | uv |
17
18 ## Quick Start
19
20 ```bash
21 # Start the stack
 
22 docker compose up -d --build
23
24 # Run migrations and seed data
25 docker compose exec backend python manage.py migrate
26 docker compose exec backend python manage.py seed
27
28 # Open the app
29 open http://localhost:8000
30 ```
31
32 **Default users:**
33 - `admin` / `admin` (superuser, full access)
34 - `viewer` / `viewer` (view-only permissions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
36 ## Architecture
37
38 ```
39 Browser
40 +-- Django Templates + HTMX + Alpine.js + Tailwind CSS
41 |
42 v (standard HTTP + HTMX partial responses)
43 |
44 Django 5 (Views, ORM, Permissions)
45 |-- Celery (async tasks)
46 |-- PostgreSQL 16 (data)
47 +-- Redis 7 (cache, sessions, broker)
 
 
 
 
 
 
 
 
 
48 ```
49
50 No separate frontend service. Django serves everything — templates, static files, and HTMX partials.
51
52 ## Endpoints
53
54 | Path | Description |
55 |------|------------|
56 | `/` | Redirects to dashboard |
57 | `/dashboard/` | Main dashboard |
58 | `/items/` | Item list with HTMX search |
59 | `/items/create/` | Create item form |
60 | `/items/<slug>/` | Item detail |
61 | `/items/<slug>/edit/` | Edit item form |
62 | `/items/<slug>/delete/` | Delete confirmation |
63 | `/auth/login/` | Login page |
64 | `/auth/logout/` | Logout |
65 | `/admin/` | Django admin |
66 | `/health/` | Health check (JSON) |
67
68 ## Development
69
70 ```bash
71 # Local development (without Docker)
72 uv sync --all-extras
73 POSTGRES_HOST=localhost POSTGRES_PORT=5434 uv run python manage.py runserver
74
75 # Run tests
76 make test
77
78 # Run linter
79 make lint
80 ```
81
82 ## Conventions
83
84 See [`bootstrap.md`](bootstrap.md) for the full conventions document.
85
86 Key patterns:
87 - All models inherit `Tracking` (audit trails) or `BaseCoreModel` (named entities with UUID)
88 - Soft deletes only — never call `.delete()` on business objects
89 - Group-based permissions checked in every view via `P.PERMISSION.check(user)`
90 - HTMX partials for dynamic updates, Alpine.js for client-side state
91 - Tests against real Postgres, both allowed and denied permission cases
92
93 ---
94
95 Fossilrepo is a [CONFLICT](https://weareconflict.com) brand. CONFLICT is a registered trademark of CONFLICT LLC.
96
--- README.md
+++ README.md
@@ -1,95 +1,189 @@
1 # Fossilrepo
2
3 **Self-hosted Fossil forge with a modern web interface.**
4
5 Fossilrepo wraps [Fossil SCM](https://fossil-scm.org) with a Django + HTMX management layer, replacing Fossil's built-in web UI with a GitHub/GitLab-caliber experience while preserving everything that makes Fossil unique: single-file repos, built-in wiki, tickets, forum, and technotes.
6
7 ## Why Fossilrepo?
8
9 Fossil is the most underrated version control system. Every repository is a single SQLite file containing your code, wiki, tickets, forum, and technotes. No external services, no complex setup. But its web UI hasn't changed since 1998.
10
11 Fossilrepo fixes that. You get:
12
13 - A modern dark/light UI built with Django, HTMX, Alpine.js, and Tailwind CSS
14 - GitHub-style code browser with line numbers, blame, history, and syntax highlighting
15 - Timeline with DAG graph showing fork/merge connectors and color-coded branches
16 - Full ticket CRUD with filters, comments, and CSV export
17 - Wiki with Markdown + Fossil markup + Pikchr diagram rendering
18 - Forum with threaded discussions
19 - Releases with file attachments and markdown changelogs
20 - Git mirror sync to GitHub/GitLab via OAuth
21 - Clone/push/pull over HTTP and SSH through Django's auth layer
22 - Webhook dispatch with HMAC signing and delivery logs
23 - Omnibus Docker image with Fossil compiled from source
24
25 All while Fossil remains the source of truth. Fossilrepo reads `.fossil` files directly via SQLite for speed, and uses the `fossil` CLI for writes to preserve artifact integrity.
26
27 ## Stack
28
29 | Layer | Technology |
30 |-------|-----------|
31 | Backend | Django 5 (Python 3.12+) |
32 | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN) |
33 | Database | PostgreSQL 16 (app data) + SQLite (Fossil repos) |
34 | Cache/Broker | Redis 7 |
35 | Jobs | Celery + Redis |
36 | Auth | Session-based (httpOnly cookies) |
37 | SCM | Fossil 2.24 (compiled from source in Docker) |
38 | Linter | Ruff |
 
39
40 ## Quick Start
41
42 ```bash
43 git clone https://github.com/ConflictHQ/fossilrepo.git
44 cd fossilrepo
45 docker compose up -d --build
46
47 # Run migrations and seed sample data
48 docker compose exec backend python manage.py migrate
49 docker compose exec backend python manage.py seed
50
51 # Open the app
52 open http://localhost:8000
53 ```
54
55 **Default users:** `admin` / `admin` (superuser) and `viewer` / `viewer` (read-only).
56
57 ## Features
58
59 ### Code Browser
60 - Directory navigation with breadcrumbs
61 - Syntax-highlighted source view with line numbers and permalinks
62 - Blame with age-based coloring (newest = brand red, oldest = gray)
63 - File history, raw download, rendered preview for Markdown/HTML
64
65 ### Timeline
66 - DAG graph with fork/merge connectors, color-coded branches
67 - Merge commit diamonds, leaf indicators
68 - Keyboard navigation (j/k/Enter), HTMX infinite scroll
69 - RSS feed
70
71 ### Diffs
72 - Unified and side-by-side view (toggle with localStorage preference)
73 - Syntax highlighting via highlight.js
74 - Line-level permalinks
75 - Compare any two checkins
76
77 ### Tickets
78 - Filter by status, type, priority, severity
79 - Full CRUD: create, edit, close/reopen, comment
80 - CSV export
81 - Pagination with configurable page size
82
83 ### Wiki
84 - Markdown + Fossil wiki markup + raw HTML
85 - Pikchr diagram rendering
86 - Right-sidebar table of contents
87 - Create and edit pages
88
89 ### Forum
90 - Threaded discussions (Fossil-native + Django-backed posts)
91 - Create threads, post replies
92 - Markdown body with preview
93
94 ### Releases
95 - Versioned releases with tag names and markdown changelogs
96 - File attachments with download counts
97 - Draft and prerelease support
98
99 ### Sync
100 - Pull from upstream Fossil remotes
101 - Git mirror to GitHub/GitLab (OAuth or SSH key auth)
102 - Clone/push/pull over HTTP via `fossil http` CGI proxy
103 - SSH push via restricted sshd (port 2222)
104 - Configurable sync modes: on-change, scheduled, both
105
106 ### Webhooks
107 - Outbound webhooks on checkin, ticket, wiki, and release events
108 - HMAC-SHA256 signed payloads
109 - Exponential backoff retry (3 attempts)
110 - Delivery log with response status and timing
111
112 ### Organization
113 - Single-org model with teams and members
114 - User CRUD: create, edit, deactivate, change password
115 - Team management with member assignment
116 - Project-level team roles: read, write, admin
117 - Project visibility: public, internal, private
118
119 ### Infrastructure
120 - Omnibus Docker image (Fossil compiled from source)
121 - Caddy for SSL termination and subdomain routing
122 - Litestream for continuous SQLite-to-S3 replication
123 - Celery Beat for scheduled metadata sync and upstream checks
124 - Encrypted credential storage (Fernet/AES-128-CBC at rest)
125
126 ## Architecture
127
128 ```
129 Browser
130 |
131 v
132 Django 5 + HTMX + Alpine.js + Tailwind CSS
133 |
134 |-- FossilReader (direct SQLite reads from .fossil files)
135 |-- FossilCLI (subprocess wrapper for write operations)
136 |-- fossil http (CGI proxy for clone/push/pull)
137 |
138 |-- PostgreSQL 16 (orgs, users, teams, projects, settings)
139 |-- Redis 7 (Celery broker, cache)
140 |-- Celery (background sync, webhooks, notifications)
141 |
142 v
143 .fossil files (SQLite — code + wiki + tickets + forum + technotes)
144 |
145 v
146 Litestream --> S3 (continuous backup)
147 ```
148
149 No separate frontend service. Django serves everything: templates, static files, and HTMX partials.
150
151 ## Configuration
152
153 All runtime settings are configurable via Django admin (Constance):
154
155 | Setting | Default | Description |
156 |---------|---------|-------------|
157 | `SITE_NAME` | Fossilrepo | Display name |
158 | `FOSSIL_DATA_DIR` | /data/repos | Where .fossil files live |
159 | `FOSSIL_BINARY_PATH` | fossil | Path to the fossil binary |
160 | `FOSSIL_STORE_IN_DB` | false | Store .fossil snapshots via Django file storage |
161 | `FOSSIL_S3_TRACKING` | false | Track S3/Litestream replication |
162 | `GIT_SYNC_MODE` | disabled | Default sync mode for new mirrors |
163 | `GIT_SYNC_SCHEDULE` | */15 * * * * | Default cron for scheduled sync |
164
165 See [`.env.example`](.env.example) for all environment variables and [`.env.production.example`](.env.production.example) for production configuration.
166
167 ## Development
168
169 ```bash
170 # Local development (without Docker)
171 uv sync --all-extras
172 DJANGO_DEBUG=true POSTGRES_HOST=localhost uv run python manage.py runserver
173
174 # Run tests
175 DJANGO_DEBUG=true uv run pytest
176
177 # Lint
178 ruff check . && ruff format --check .
179 ```
180
181 See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full development guide and [`bootstrap.md`](bootstrap.md) for codebase conventions.
182
183 ## License
184
185 MIT License. See [LICENSE](LICENSE) for details.
 
 
 
 
 
186
187 ---
188
189 Built by [CONFLICT](https://weareconflict.com). Fossilrepo is open source under the MIT license.
190
+36 -11
--- SECURITY.md
+++ SECURITY.md
@@ -4,11 +4,11 @@
44
55
If you discover a security vulnerability in Fossilrepo, please report it responsibly.
66
77
**Do not open a public issue.**
88
9
-Instead, email **[email protected]** with:
9
+Email **[email protected]** with:
1010
1111
- Description of the vulnerability
1212
- Steps to reproduce
1313
- Potential impact
1414
- Suggested fix (if any)
@@ -19,15 +19,40 @@
1919
2020
| Version | Supported |
2121
| ------- | --------- |
2222
| latest | Yes |
2323
24
-## Security Best Practices
25
-
26
-When deploying Fossilrepo:
27
-
28
-- Change all default credentials (database, MinIO, session secret)
29
-- Use HTTPS in production
30
-- Set `NODE_ENV=production`
31
-- Configure `CORS_ORIGINS` to your domain only
32
-- Use strong Auth0 credentials
33
-- Review the security hardening in `bootstrap.md`
24
+## Security Model
25
+
26
+### Authentication
27
+
28
+- Session-based authentication with httpOnly, secure cookies
29
+- CSRF protection on all forms (HTMX includes token via `htmx:configRequest`)
30
+- Rate limiting on login (10 attempts/minute per IP)
31
+- Password validation enforced (Django's built-in validators)
32
+
33
+### Authorization
34
+
35
+- Group-based permissions via `P` enum (`core/permissions.py`)
36
+- Project-level RBAC: read, write, admin roles via team membership
37
+- Project visibility: public (anonymous read), internal (authenticated), private (team members only)
38
+- All views enforce permission checks before data access
39
+
40
+### Data Protection
41
+
42
+- SSH keys and OAuth tokens encrypted at rest (Fernet/AES-128-CBC, keyed from `SECRET_KEY`)
43
+- No plaintext credentials stored in the database
44
+- Fossil sync uses `--localauth` only for authenticated users with write access
45
+- Anonymous users get pull-only access on public repos (no `--localauth`)
46
+
47
+### Deployment
48
+
49
+When deploying Fossilrepo in production:
50
+
51
+- Set a strong, unique `DJANGO_SECRET_KEY` (the app refuses to start without one when `DEBUG=False`)
52
+- Change all default database credentials
53
+- Enable HTTPS (`SECURE_SSL_REDIRECT`, `SESSION_COOKIE_SECURE`, `CSRF_COOKIE_SECURE` are automatic when `DEBUG=False`)
54
+- Set `DJANGO_ALLOWED_HOSTS` to your domain only
55
+- Set `CORS_ALLOWED_ORIGINS` and `CSRF_TRUSTED_ORIGINS` to your domain
56
+- Review Constance settings in Django admin (OAuth secrets, S3 credentials)
57
+- Use a reverse proxy (Caddy/nginx) for SSL termination
58
+- Keep the Fossil binary updated (compiled from source in the Docker image)
3459
3560
DELETED _old_CLAUDE.md
3661
DELETED _old_bootstrap.md
3762
ADDED accounts/__init__.py
3863
ADDED accounts/admin.py
3964
ADDED accounts/apps.py
4065
ADDED accounts/forms.py
4166
ADDED accounts/migrations/0001_initial.py
4267
ADDED accounts/migrations/__init__.py
4368
ADDED accounts/models.py
4469
ADDED accounts/tests.py
4570
ADDED accounts/urls.py
4671
ADDED accounts/views.py
4772
DELETED auth1/__init__.py
4873
DELETED auth1/apps.py
4974
DELETED auth1/forms.py
5075
DELETED auth1/migrations/__init__.py
5176
DELETED auth1/tests.py
5277
DELETED auth1/urls.py
5378
DELETED auth1/views.py
--- SECURITY.md
+++ SECURITY.md
@@ -4,11 +4,11 @@
4
5 If you discover a security vulnerability in Fossilrepo, please report it responsibly.
6
7 **Do not open a public issue.**
8
9 Instead, email **[email protected]** with:
10
11 - Description of the vulnerability
12 - Steps to reproduce
13 - Potential impact
14 - Suggested fix (if any)
@@ -19,15 +19,40 @@
19
20 | Version | Supported |
21 | ------- | --------- |
22 | latest | Yes |
23
24 ## Security Best Practices
25
26 When deploying Fossilrepo:
27
28 - Change all default credentials (database, MinIO, session secret)
29 - Use HTTPS in production
30 - Set `NODE_ENV=production`
31 - Configure `CORS_ORIGINS` to your domain only
32 - Use strong Auth0 credentials
33 - Review the security hardening in `bootstrap.md`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
35 ELETED _old_CLAUDE.md
36 ELETED _old_bootstrap.md
37 DDED accounts/__init__.py
38 DDED accounts/admin.py
39 DDED accounts/apps.py
40 DDED accounts/forms.py
41 DDED accounts/migrations/0001_initial.py
42 DDED accounts/migrations/__init__.py
43 DDED accounts/models.py
44 DDED accounts/tests.py
45 DDED accounts/urls.py
46 DDED accounts/views.py
47 ELETED auth1/__init__.py
48 ELETED auth1/apps.py
49 ELETED auth1/forms.py
50 ELETED auth1/migrations/__init__.py
51 ELETED auth1/tests.py
52 ELETED auth1/urls.py
53 ELETED auth1/views.py
--- SECURITY.md
+++ SECURITY.md
@@ -4,11 +4,11 @@
4
5 If you discover a security vulnerability in Fossilrepo, please report it responsibly.
6
7 **Do not open a public issue.**
8
9 Email **[email protected]** with:
10
11 - Description of the vulnerability
12 - Steps to reproduce
13 - Potential impact
14 - Suggested fix (if any)
@@ -19,15 +19,40 @@
19
20 | Version | Supported |
21 | ------- | --------- |
22 | latest | Yes |
23
24 ## Security Model
25
26 ### Authentication
27
28 - Session-based authentication with httpOnly, secure cookies
29 - CSRF protection on all forms (HTMX includes token via `htmx:configRequest`)
30 - Rate limiting on login (10 attempts/minute per IP)
31 - Password validation enforced (Django's built-in validators)
32
33 ### Authorization
34
35 - Group-based permissions via `P` enum (`core/permissions.py`)
36 - Project-level RBAC: read, write, admin roles via team membership
37 - Project visibility: public (anonymous read), internal (authenticated), private (team members only)
38 - All views enforce permission checks before data access
39
40 ### Data Protection
41
42 - SSH keys and OAuth tokens encrypted at rest (Fernet/AES-128-CBC, keyed from `SECRET_KEY`)
43 - No plaintext credentials stored in the database
44 - Fossil sync uses `--localauth` only for authenticated users with write access
45 - Anonymous users get pull-only access on public repos (no `--localauth`)
46
47 ### Deployment
48
49 When deploying Fossilrepo in production:
50
51 - Set a strong, unique `DJANGO_SECRET_KEY` (the app refuses to start without one when `DEBUG=False`)
52 - Change all default database credentials
53 - Enable HTTPS (`SECURE_SSL_REDIRECT`, `SESSION_COOKIE_SECURE`, `CSRF_COOKIE_SECURE` are automatic when `DEBUG=False`)
54 - Set `DJANGO_ALLOWED_HOSTS` to your domain only
55 - Set `CORS_ALLOWED_ORIGINS` and `CSRF_TRUSTED_ORIGINS` to your domain
56 - Review Constance settings in Django admin (OAuth secrets, S3 credentials)
57 - Use a reverse proxy (Caddy/nginx) for SSL termination
58 - Keep the Fossil binary updated (compiled from source in the Docker image)
59
60 ELETED _old_CLAUDE.md
61 ELETED _old_bootstrap.md
62 DDED accounts/__init__.py
63 DDED accounts/admin.py
64 DDED accounts/apps.py
65 DDED accounts/forms.py
66 DDED accounts/migrations/0001_initial.py
67 DDED accounts/migrations/__init__.py
68 DDED accounts/models.py
69 DDED accounts/tests.py
70 DDED accounts/urls.py
71 DDED accounts/views.py
72 ELETED auth1/__init__.py
73 ELETED auth1/apps.py
74 ELETED auth1/forms.py
75 ELETED auth1/migrations/__init__.py
76 ELETED auth1/tests.py
77 ELETED auth1/urls.py
78 ELETED auth1/views.py
D _old_CLAUDE.md
-57
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -1,57 +0,0 @@
1
-# CLAUDE.md -- fossilrepo
2
-
3
-## Project Overview
4
-
5
-fossilrepo is a self-hosted Fossil SCM server infrastructure tool. It provides Docker + Caddy + Litestream hosting for Fossil repositories, a CLI wrapper around fossil commands, and a sync bridge to mirror Fossil repos to GitHub/GitLab.
6
-
7
-Open source (MIT). Part of the CONFLICT ecosystem.
8
-
9
-## Repository Structure
10
-
11
-```
12
-fossilrepo/
13
-├── fossilrepo/ # Python package
14
-│ ├── server/ # Fossil server management (Docker, Caddy, Litestream)
15
-│ │ ├── config.py # Pydantic server configuration
16
-│ │ └── manager.py # Repo lifecycle (create, delete, list)
17
-│ ├── sync/ # Fossil → Git mirror
18
-│ │ ├── mirror.py # Core sync logic (commits, tickets, wiki)
19
-│ │ └── mappings.py # Data models for Fossil↔Git mappings
20
-│ └── cli/ # Click CLI
21
-│ └── main.py # CLI entrypoint (server, repo, sync commands)
22
-├── docker/ # Container configs
23
-│ ├── Dockerfile # Fossil + Caddy + Litestream
24
-│ ├── docker-compose.yml # Local dev stack
25
-│ ├── Caddyfile # Subdomain routing
26
-│ └── litestream.yml # S3 replication
27
-├── tests/ # pytest, mirrors fossilrepo/
28
-├── docs/ # Architecture, guides
29
-├── fossil-platform/ # Old exploration (Flask + React), kept for reference
30
-├── bootstrap.md # Project bootstrap doc — read first
31
-└── AGENTS.md # Agent conventions pointer
32
-```
33
-
34
-## Key Conventions
35
-
36
-- Python 3.11+, typed with Pydantic models
37
-- Click for CLI, Rich for terminal output
38
-- Ruff for linting, pytest for testing
39
-- Fossil is the source of truth; Git remotes are downstream mirrors
40
-- Server infra: Docker + Caddy (SSL, subdomain routing) + Litestream (S3 replication)
41
-- Each repo is a single .fossil file (SQLite) — Litestream replicates it continuously
42
-
43
-## Development
44
-
45
-```bash
46
-pip install -e ".[dev]"
47
-pytest
48
-ruff check .
49
-```
50
-
51
-## CLI
52
-
53
-```bash
54
-fossilrepo server start|stop|status
55
-fossilrepo repo create|list|delete
56
-fossilrepo sync run|status
57
-```
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -1,57 +0,0 @@
1 # CLAUDE.md -- fossilrepo
2
3 ## Project Overview
4
5 fossilrepo is a self-hosted Fossil SCM server infrastructure tool. It provides Docker + Caddy + Litestream hosting for Fossil repositories, a CLI wrapper around fossil commands, and a sync bridge to mirror Fossil repos to GitHub/GitLab.
6
7 Open source (MIT). Part of the CONFLICT ecosystem.
8
9 ## Repository Structure
10
11 ```
12 fossilrepo/
13 ├── fossilrepo/ # Python package
14 │ ├── server/ # Fossil server management (Docker, Caddy, Litestream)
15 │ │ ├── config.py # Pydantic server configuration
16 │ │ └── manager.py # Repo lifecycle (create, delete, list)
17 │ ├── sync/ # Fossil → Git mirror
18 │ │ ├── mirror.py # Core sync logic (commits, tickets, wiki)
19 │ │ └── mappings.py # Data models for Fossil↔Git mappings
20 │ └── cli/ # Click CLI
21 │ └── main.py # CLI entrypoint (server, repo, sync commands)
22 ├── docker/ # Container configs
23 │ ├── Dockerfile # Fossil + Caddy + Litestream
24 │ ├── docker-compose.yml # Local dev stack
25 │ ├── Caddyfile # Subdomain routing
26 │ └── litestream.yml # S3 replication
27 ├── tests/ # pytest, mirrors fossilrepo/
28 ├── docs/ # Architecture, guides
29 ├── fossil-platform/ # Old exploration (Flask + React), kept for reference
30 ├── bootstrap.md # Project bootstrap doc — read first
31 └── AGENTS.md # Agent conventions pointer
32 ```
33
34 ## Key Conventions
35
36 - Python 3.11+, typed with Pydantic models
37 - Click for CLI, Rich for terminal output
38 - Ruff for linting, pytest for testing
39 - Fossil is the source of truth; Git remotes are downstream mirrors
40 - Server infra: Docker + Caddy (SSL, subdomain routing) + Litestream (S3 replication)
41 - Each repo is a single .fossil file (SQLite) — Litestream replicates it continuously
42
43 ## Development
44
45 ```bash
46 pip install -e ".[dev]"
47 pytest
48 ruff check .
49 ```
50
51 ## CLI
52
53 ```bash
54 fossilrepo server start|stop|status
55 fossilrepo repo create|list|delete
56 fossilrepo sync run|status
57 ```
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -1,57 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
D _old_bootstrap.md
-84
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -1,89 +0,0 @@
1
-# fossilrepo — bootstrap
2
-
3
-Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups — all powered by Fossil SCM.
4
-
5
-Think GitLab Omnibus, but for Fossil.
6
-
----
7
-
8
-## Why Fossil
9
-
10
-A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable — hand the file to someone and they have everything.
11
-
12
-For teams running CI agents or automation:
13
-- Agents commit, file tickets, and update the wiki through one CLI and one protocol
14
-- No API rate limits when many agents are pushing simultaneously
15
-- The `.fossil` file IS the project artifact — a self-contained archive
16
-- Litestream replicates it to S3 continuously — backup and point-in-time recovery for free
17
-
18
-Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
19
-
----
20
-
21
-## What fossilrepo Does
22
-
23
-fossilrepo packages everything needed to run a production Fossil server into one installable unit:
24
-
25
-- **Fossil server** — serves all repos from a single process
26
-- **Caddy** — SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
27
-- **Litestream** — continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
28
-- **CLI** — repo lifecycle management (create, list, delete) and sync tooling
29
-- **Sync bridge** — mirror Fossil repos to GitHub/GitLab as downstream read-only copies
30
-
31
-New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
32
-
----
33
-
34
-## Architecture
35
-
36
-```
37
-fossilrepo/
38
-├── server/ # Fossil server infra — Docker, Caddy, Litestream
39
-├── sync/ # Fossil → GitHub/GitLab mirror
40
-├── cli/ # fossilrepo CLI wrapper
41
-└── docs/ # Architecture, guides
42
-```
43
-
44
-### Server Stack
45
-
46
-```
47
-Caddy (SSL termination, routing, subdomain per repo)
48
- └── fossil server --repolist /data/repos/
49
- └── /data/repos/
50
- ├── projecta.fossil
51
- ├── projectb.fossil
52
- └── ...
53
-
54
-Litestream → S3/MinIO (continuous replication, point-in-time recovery)
55
-```
56
-
57
-One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
58
-
59
-### Sync Bridge
60
-
61
-Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
62
-
63
-Maps:
64
-- Fossil commits → Git commits
65
-- Fossil tickets → GitHub/GitLab Issues (optional, configurable)
66
-- Fossil wiki → repo docs (optional, configurable)
67
-
68
-Triggered on demand or on schedule.
69
-
----
70
-
71
-## Platform Vision (fossilrepos.com)
72
-
73
-GitLab model:
74
-- **Self-hosted** — open source, run it yourself. fossilrepo is the tool.
75
-- **Managed** — fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
76
-
77
-The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite — Fossil already does the hard parts. The value is the hosting and UX polish.
78
-
79
-Not being built yet — get the self-hosted tool right first.
80
-
----
81
-
82
-## License
83
-
84
-MIT.
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -1,89 +0,0 @@
1 # fossilrepo — bootstrap
2
3 Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups — all powered by Fossil SCM.
4
5 Think GitLab Omnibus, but for Fossil.
6
----
7
8 ## Why Fossil
9
10 A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable — hand the file to someone and they have everything.
11
12 For teams running CI agents or automation:
13 - Agents commit, file tickets, and update the wiki through one CLI and one protocol
14 - No API rate limits when many agents are pushing simultaneously
15 - The `.fossil` file IS the project artifact — a self-contained archive
16 - Litestream replicates it to S3 continuously — backup and point-in-time recovery for free
17
18 Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
19
----
20
21 ## What fossilrepo Does
22
23 fossilrepo packages everything needed to run a production Fossil server into one installable unit:
24
25 - **Fossil server** — serves all repos from a single process
26 - **Caddy** — SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
27 - **Litestream** — continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
28 - **CLI** — repo lifecycle management (create, list, delete) and sync tooling
29 - **Sync bridge** — mirror Fossil repos to GitHub/GitLab as downstream read-only copies
30
31 New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
32
----
33
34 ## Architecture
35
36 ```
37 fossilrepo/
38 ├── server/ # Fossil server infra — Docker, Caddy, Litestream
39 ├── sync/ # Fossil → GitHub/GitLab mirror
40 ├── cli/ # fossilrepo CLI wrapper
41 └── docs/ # Architecture, guides
42 ```
43
44 ### Server Stack
45
46 ```
47 Caddy (SSL termination, routing, subdomain per repo)
48 └── fossil server --repolist /data/repos/
49 └── /data/repos/
50 ├── projecta.fossil
51 ├── projectb.fossil
52 └── ...
53
54 Litestream → S3/MinIO (continuous replication, point-in-time recovery)
55 ```
56
57 One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
58
59 ### Sync Bridge
60
61 Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
62
63 Maps:
64 - Fossil commits → Git commits
65 - Fossil tickets → GitHub/GitLab Issues (optional, configurable)
66 - Fossil wiki → repo docs (optional, configurable)
67
68 Triggered on demand or on schedule.
69
----
70
71 ## Platform Vision (fossilrepos.com)
72
73 GitLab model:
74 - **Self-hosted** — open source, run it yourself. fossilrepo is the tool.
75 - **Managed** — fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
76
77 The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite — Fossil already does the hard parts. The value is the hosting and UX polish.
78
79 Not being built yet — get the self-hosted tool right first.
80
----
81
82 ## License
83
84 MIT.
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -1,89 +0,0 @@
 
 
 
 
 
 
----
 
 
 
 
 
 
 
 
 
 
 
 
 
----
 
 
 
 
 
 
 
 
 
 
 
 
 
----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
----
 
 
 
 
 
 
 
 
 
 
 
----
 
 
 
 

No diff available

--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -0,0 +1,20 @@
1
+from django.contrib import admin
2
+
3
+from .models import PersonalAccessToken, UserProfile
4
+
5
+
6
+@admin.register(UserProfile)
7
+class UserProfileAdmin(admin.ModelAdmin):
8
+ list_display = ("user", "handle", "location")
9
+ search_fields = ("user__username", "handle", "location")
10
+ raw_id_fields = ("user",)
11
+ readonly_fields = ("user",)
12
+
13
+
14
+@admin.register(PersonalAccessToken)
15
+class PersonalAccessTokenAdmin(admin.ModelAdmin):
16
+ list_display = ("name", "user", "token_prefix", "scopes", "created_at", "expires_at", "last_used_at", "revoked_at")
17
+ list_filter = ("scopes",)
18
+ search_fields = ("name", "user__username", "token_prefix")
19
+ raw_id_fields = ("user",)
20
+ readonly_fields = ("token_hash", "token_prefix", "created_at", "last_used_at")
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -0,0 +1,20 @@
1 from django.contrib import admin
2
3 from .models import PersonalAccessToken, UserProfile
4
5
6 @admin.register(UserProfile)
7 class UserProfileAdmin(admin.ModelAdmin):
8 list_display = ("user", "handle", "location")
9 search_fields = ("user__username", "handle", "location")
10 raw_id_fields = ("user",)
11 readonly_fields = ("user",)
12
13
14 @admin.register(PersonalAccessToken)
15 class PersonalAccessTokenAdmin(admin.ModelAdmin):
16 list_display = ("name", "user", "token_prefix", "scopes", "created_at", "expires_at", "last_used_at", "revoked_at")
17 list_filter = ("scopes",)
18 search_fields = ("name", "user__username", "token_prefix")
19 raw_id_fields = ("user",)
20 readonly_fields = ("token_hash", "token_prefix", "created_at", "last_used_at")
--- a/accounts/apps.py
+++ b/accounts/apps.py
@@ -0,0 +1,7 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class Auth1Config(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "accounts"
7
+ verbose_name = "Authentication"
--- a/accounts/apps.py
+++ b/accounts/apps.py
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
--- a/accounts/apps.py
+++ b/accounts/apps.py
@@ -0,0 +1,7 @@
1 from django.apps import AppConfig
2
3
4 class Auth1Config(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "accounts"
7 verbose_name = "Authentication"
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -0,0 +1,22 @@
1
+from django import forms
2
+from django.contrib.auth.forms import AuthenticationForm
3
+
4
+
5
+class LoginForm(AuthenticationForm):
6
+ username = forms.CharField(
7
+ widget=forms.TextInput(
8
+ attrs={
9
+ "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10
+ "placeholder": "Username",
11
+ "autofocus": True,
12
+ }
13
+ )
14
+ )
15
+ password = forms.CharField(
16
+ widget=forms.PasswordInput(
17
+ attrs={
18
+ "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19
+ "placeholder": "Password",
20
+ }
21
+ )
22
+ )
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -0,0 +1,22 @@
1 from django import forms
2 from django.contrib.auth.forms import AuthenticationForm
3
4
5 class LoginForm(AuthenticationForm):
6 username = forms.CharField(
7 widget=forms.TextInput(
8 attrs={
9 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10 "placeholder": "Username",
11 "autofocus": True,
12 }
13 )
14 )
15 password = forms.CharField(
16 widget=forms.PasswordInput(
17 attrs={
18 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19 "placeholder": "Password",
20 }
21 )
22 )
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,92 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 15:40
2
+
3
+import django.db.models.deletion
4
+from django.conf import settings
5
+from django.db import migrations, models
6
+
7
+
8
+class Migration(migrations.Migration):
9
+ initial = True
10
+
11
+ dependencies = [
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="PersonalAccessToken",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigAutoField(
22
+ auto_created=True,
23
+ primary_key=True,
24
+ serialize=False,
25
+ verbose_name="ID",
26
+ ),
27
+ ),
28
+ ("name", models.CharField(max_length=200)),
29
+ ("token_hash", models.CharField(max_length=64, unique=True)),
30
+ ("token_prefix", models.CharField(max_length=12)),
31
+ (
32
+ "scopes",
33
+ models.CharField(
34
+ default="read",
35
+ help_text="Comma-separated: read, write, admin",
36
+ max_length=500,
37
+ ),
38
+ ),
39
+ ("expires_at", models.DateTimeField(blank=True, null=True)),
40
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
41
+ ("created_at", models.DateTimeField(auto_now_add=True)),
42
+ ("revoked_at", models.DateTimeField(blank=True, null=True)),
43
+ (
44
+ "user",
45
+ models.ForeignKey(
46
+ on_delete=django.db.models.deletion.CASCADE,
47
+ related_name="personal_tokens",
48
+ to=settings.AUTH_USER_MODEL,
49
+ ),
50
+ ),
51
+ ],
52
+ options={
53
+ "ordering": ["-created_at"],
54
+ },
55
+ ),
56
+ migrations.CreateModel(
57
+ name="UserProfile",
58
+ fields=[
59
+ (
60
+ "id",
61
+ models.BigAutoField(
62
+ auto_created=True,
63
+ primary_key=True,
64
+ serialize=False,
65
+ verbose_name="ID",
66
+ ),
67
+ ),
68
+ (
69
+ "handle",
70
+ models.CharField(
71
+ blank=True,
72
+ default=None,
73
+ help_text="@handle for mentions (alphanumeric and hyphens only)",
74
+ max_length=50,
75
+ null=True,
76
+ unique=True,
77
+ ),
78
+ ),
79
+ ("bio", models.TextField(blank=True, default="", max_length=500)),
80
+ ("location", models.CharField(blank=True, default="", max_length=100)),
81
+ ("website", models.URLField(blank=True, default="")),
82
+ (
83
+ "user",
84
+ models.OneToOneField(
85
+ on_delete=django.db.models.deletion.CASCADE,
86
+ related_name="profile",
87
+ to=settings.AUTH_USER_MODEL,
88
+ ),
89
+ ),
90
+ ],
91
+ ),
92
+ ]
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,92 @@
1 # Generated by Django 5.2.12 on 2026-04-07 15:40
2
3 import django.db.models.deletion
4 from django.conf import settings
5 from django.db import migrations, models
6
7
8 class Migration(migrations.Migration):
9 initial = True
10
11 dependencies = [
12 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 ]
14
15 operations = [
16 migrations.CreateModel(
17 name="PersonalAccessToken",
18 fields=[
19 (
20 "id",
21 models.BigAutoField(
22 auto_created=True,
23 primary_key=True,
24 serialize=False,
25 verbose_name="ID",
26 ),
27 ),
28 ("name", models.CharField(max_length=200)),
29 ("token_hash", models.CharField(max_length=64, unique=True)),
30 ("token_prefix", models.CharField(max_length=12)),
31 (
32 "scopes",
33 models.CharField(
34 default="read",
35 help_text="Comma-separated: read, write, admin",
36 max_length=500,
37 ),
38 ),
39 ("expires_at", models.DateTimeField(blank=True, null=True)),
40 ("last_used_at", models.DateTimeField(blank=True, null=True)),
41 ("created_at", models.DateTimeField(auto_now_add=True)),
42 ("revoked_at", models.DateTimeField(blank=True, null=True)),
43 (
44 "user",
45 models.ForeignKey(
46 on_delete=django.db.models.deletion.CASCADE,
47 related_name="personal_tokens",
48 to=settings.AUTH_USER_MODEL,
49 ),
50 ),
51 ],
52 options={
53 "ordering": ["-created_at"],
54 },
55 ),
56 migrations.CreateModel(
57 name="UserProfile",
58 fields=[
59 (
60 "id",
61 models.BigAutoField(
62 auto_created=True,
63 primary_key=True,
64 serialize=False,
65 verbose_name="ID",
66 ),
67 ),
68 (
69 "handle",
70 models.CharField(
71 blank=True,
72 default=None,
73 help_text="@handle for mentions (alphanumeric and hyphens only)",
74 max_length=50,
75 null=True,
76 unique=True,
77 ),
78 ),
79 ("bio", models.TextField(blank=True, default="", max_length=500)),
80 ("location", models.CharField(blank=True, default="", max_length=100)),
81 ("website", models.URLField(blank=True, default="")),
82 (
83 "user",
84 models.OneToOneField(
85 on_delete=django.db.models.deletion.CASCADE,
86 related_name="profile",
87 to=settings.AUTH_USER_MODEL,
88 ),
89 ),
90 ],
91 ),
92 ]

No diff available

--- a/accounts/models.py
+++ b/accounts/models.py
@@ -0,0 +1,88 @@
1
+"""User profile and personal access token models.
2
+
3
+UserProfile extends Django's built-in User with optional profile fields.
4
+PersonalAccessToken provides user-scoped tokens for API/CLI authentication,
5
+separate from project-scoped APITokens.
6
+"""
7
+
8
+import hashlib
9
+import re
10
+import secrets
11
+
12
+from django.contrib.auth.models import User
13
+from django.db import models
14
+from django.utils import timezone
15
+
16
+
17
+class UserProfile(models.Model):
18
+ """Extended profile information for users."""
19
+
20
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
21
+ handle = models.CharField(
22
+ max_length=50,
23
+ blank=True,
24
+ null=True,
25
+ default=None,
26
+ unique=True,
27
+ help_text="@handle for mentions (alphanumeric and hyphens only)",
28
+ )
29
+ bio = models.TextField(blank=True, default="", max_length=500)
30
+ location = models.CharField(max_length=100, blank=True, default="")
31
+ website = models.URLField(blank=True, default="")
32
+
33
+ def __str__(self):
34
+ return f"@{self.handle or self.user.username}"
35
+
36
+ @staticmethod
37
+ def sanitize_handle(raw: str) -> str:
38
+ """Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens."""
39
+ cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip())
40
+ return cleaned.strip("-")
41
+
42
+
43
+class PersonalAccessToken(models.Model):
44
+ """User-scoped personal access token for API/CLI authentication.
45
+
46
+ Tokens are stored as SHA-256 hashes -- the raw value is shown once on
47
+ creation and never stored in plaintext.
48
+ """
49
+
50
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens")
51
+ name = models.CharField(max_length=200)
52
+ token_hash = models.CharField(max_length=64, unique=True)
53
+ token_prefix = models.CharField(max_length=12)
54
+ scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin")
55
+ expires_at = models.DateTimeField(null=True, blank=True)
56
+ last_used_at = models.DateTimeField(null=True, blank=True)
57
+ created_at = models.DateTimeField(auto_now_add=True)
58
+ revoked_at = models.DateTimeField(null=True, blank=True)
59
+
60
+ class Meta:
61
+ ordering = ["-created_at"]
62
+
63
+ @staticmethod
64
+ def generate():
65
+ """Generate a new token. Returns (raw_token, token_hash, prefix)."""
66
+ raw = f"frp_{secrets.token_urlsafe(32)}"
67
+ hash_val = hashlib.sha256(raw.encode()).hexdigest()
68
+ prefix = raw[:12]
69
+ return raw, hash_val, prefix
70
+
71
+ @staticmethod
72
+ def hash_token(raw_token):
73
+ return hashlib.sha256(raw_token.encode()).hexdigest()
74
+
75
+ @property
76
+ def is_expired(self):
77
+ return bool(self.expires_at and self.expires_at < timezone.now())
78
+
79
+ @property
80
+ def is_revoked(self):
81
+ return self.revoked_at is not None
82
+
83
+ @property
84
+ def is_active(self):
85
+ return not self.is_expired and not self.is_revoked
86
+
87
+ def __str__(self):
88
+ return f"{self.name} ({self.token_prefix}...)"
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -0,0 +1,88 @@
1 """User profile and personal access token models.
2
3 UserProfile extends Django's built-in User with optional profile fields.
4 PersonalAccessToken provides user-scoped tokens for API/CLI authentication,
5 separate from project-scoped APITokens.
6 """
7
8 import hashlib
9 import re
10 import secrets
11
12 from django.contrib.auth.models import User
13 from django.db import models
14 from django.utils import timezone
15
16
17 class UserProfile(models.Model):
18 """Extended profile information for users."""
19
20 user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
21 handle = models.CharField(
22 max_length=50,
23 blank=True,
24 null=True,
25 default=None,
26 unique=True,
27 help_text="@handle for mentions (alphanumeric and hyphens only)",
28 )
29 bio = models.TextField(blank=True, default="", max_length=500)
30 location = models.CharField(max_length=100, blank=True, default="")
31 website = models.URLField(blank=True, default="")
32
33 def __str__(self):
34 return f"@{self.handle or self.user.username}"
35
36 @staticmethod
37 def sanitize_handle(raw: str) -> str:
38 """Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens."""
39 cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip())
40 return cleaned.strip("-")
41
42
43 class PersonalAccessToken(models.Model):
44 """User-scoped personal access token for API/CLI authentication.
45
46 Tokens are stored as SHA-256 hashes -- the raw value is shown once on
47 creation and never stored in plaintext.
48 """
49
50 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens")
51 name = models.CharField(max_length=200)
52 token_hash = models.CharField(max_length=64, unique=True)
53 token_prefix = models.CharField(max_length=12)
54 scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin")
55 expires_at = models.DateTimeField(null=True, blank=True)
56 last_used_at = models.DateTimeField(null=True, blank=True)
57 created_at = models.DateTimeField(auto_now_add=True)
58 revoked_at = models.DateTimeField(null=True, blank=True)
59
60 class Meta:
61 ordering = ["-created_at"]
62
63 @staticmethod
64 def generate():
65 """Generate a new token. Returns (raw_token, token_hash, prefix)."""
66 raw = f"frp_{secrets.token_urlsafe(32)}"
67 hash_val = hashlib.sha256(raw.encode()).hexdigest()
68 prefix = raw[:12]
69 return raw, hash_val, prefix
70
71 @staticmethod
72 def hash_token(raw_token):
73 return hashlib.sha256(raw_token.encode()).hexdigest()
74
75 @property
76 def is_expired(self):
77 return bool(self.expires_at and self.expires_at < timezone.now())
78
79 @property
80 def is_revoked(self):
81 return self.revoked_at is not None
82
83 @property
84 def is_active(self):
85 return not self.is_expired and not self.is_revoked
86
87 def __str__(self):
88 return f"{self.name} ({self.token_prefix}...)"
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -0,0 +1,308 @@
1
+import pytest
2
+from django.urls import reverse
3
+
4
+from accounts.models import PersonalAccessToken, UserProfile
5
+
6
+
7
+@pytest.mark.django_db
8
+class TestLogin:
9
+ def test_login_page_renders(self, client):
10
+ response = client.get(reverse("accounts:login"))
11
+ assert response.status_code == 200
12
+ assert b"Sign in" in response.content
13
+
14
+ def test_login_success_redirects_to_dashboard(self, client, admin_user):
15
+ response = client.post(reverse("accounts:login"), {"username": "admin", "password": "testpass123"})
16
+ assert response.status_code == 302
17
+ assert response.url == reverse("dashboard")
18
+
19
+ def test_login_failure_shows_error(self, client, admin_user):
20
+ response = client.post(reverse("accounts:login"), {"username": "admin", "password": "wrong"})
21
+ assert response.status_code == 200
22
+ assert b"Invalid username or password" in response.content
23
+
24
+ def test_login_redirect_when_already_authenticated(self, admin_client):
25
+ response = admin_client.get(reverse("accounts:login"))
26
+ assert response.status_code == 302
27
+
28
+ def test_login_with_next_param(self, client, admin_user):
29
+ response = client.post(reverse("accounts:login") + "?next=/projects/", {"username": "admin", "password": "testpass123"})
30
+ assert response.status_code == 302
31
+ assert response.url == "/projects/"
32
+
33
+
34
+@pytest.mark.django_db
35
+class TestLogout:
36
+ def test_logout_redirects_to_login(self, admin_client):
37
+ response = admin_client.post(reverse("accounts:logout"))
38
+ assert response.status_code == 302
39
+ assert reverse("accounts:login") in response.url
40
+
41
+ def test_logout_clears_session(self, admin_client):
42
+ admin_client.post(reverse("accounts:logout"))
43
+ response = admin_client.get(reverse("dashboard"))
44
+ assert response.status_code == 302 # redirected to login
45
+
46
+ def test_logout_rejects_get(self, admin_client):
47
+ response = admin_client.get(reverse("accounts:logout"))
48
+ assert response.status_code == 405
49
+
50
+
51
+# ---------------------------------------------------------------------------
52
+# Profile views
53
+# ---------------------------------------------------------------------------
54
+
55
+
56
+@pytest.mark.django_db
57
+class TestProfile:
58
+ def test_profile_page_renders(self, admin_client, admin_user):
59
+ response = admin_client.get(reverse("accounts:profile"))
60
+ assert response.status_code == 200
61
+ assert b"Profile Info" in response.content
62
+ assert b"SSH Keys" in response.content
63
+ assert b"Personal Access Tokens" in response.content
64
+
65
+ def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user):
66
+ assert not UserProfile.objects.filter(user=admin_user).exists()
67
+ admin_client.get(reverse("accounts:profile"))
68
+ assert UserProfile.objects.filter(user=admin_user).exists()
69
+
70
+ def test_profile_requires_login(self, client):
71
+ response = client.get(reverse("accounts:profile"))
72
+ assert response.status_code == 302
73
+ assert "/auth/login/" in response.url
74
+
75
+ def test_profile_top_level_redirect(self, admin_client):
76
+ response = admin_client.get("/profile/")
77
+ assert response.status_code == 302
78
+ assert "/auth/profile/" in response.url
79
+
80
+
81
+@pytest.mark.django_db
82
+class TestProfileEdit:
83
+ def test_edit_page_renders(self, admin_client, admin_user):
84
+ response = admin_client.get(reverse("accounts:profile_edit"))
85
+ assert response.status_code == 200
86
+ assert b"Edit Profile" in response.content
87
+
88
+ def test_edit_updates_user_fields(self, admin_client, admin_user):
89
+ response = admin_client.post(
90
+ reverse("accounts:profile_edit"),
91
+ {
92
+ "first_name": "Alice",
93
+ "last_name": "Smith",
94
+ "email": "[email protected]",
95
+ "handle": "alice-s",
96
+ "bio": "Hello world",
97
+ "location": "NYC",
98
+ "website": "https://alice.dev",
99
+ },
100
+ )
101
+ assert response.status_code == 302
102
+ admin_user.refresh_from_db()
103
+ assert admin_user.first_name == "Alice"
104
+ assert admin_user.last_name == "Smith"
105
+ assert admin_user.email == "[email protected]"
106
+ profile = UserProfile.objects.get(user=admin_user)
107
+ assert profile.handle == "alice-s"
108
+ assert profile.bio == "Hello world"
109
+ assert profile.location == "NYC"
110
+ assert profile.website == "https://alice.dev"
111
+
112
+ def test_edit_sanitizes_handle(self, admin_client, admin_user):
113
+ admin_client.post(
114
+ reverse("accounts:profile_edit"),
115
+ {"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""},
116
+ )
117
+ profile = UserProfile.objects.get(user=admin_user)
118
+ assert profile.handle == "uppercasestuff"
119
+
120
+ def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user):
121
+ # Create a profile with handle for viewer_user
122
+ UserProfile.objects.create(user=viewer_user, handle="taken-handle")
123
+ response = admin_client.post(
124
+ reverse("accounts:profile_edit"),
125
+ {"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""},
126
+ )
127
+ assert response.status_code == 200 # re-renders form with error
128
+ assert b"already taken" in response.content
129
+
130
+ def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user):
131
+ admin_client.post(
132
+ reverse("accounts:profile_edit"),
133
+ {"handle": "", "first_name": "", "last_name": "", "email": ""},
134
+ )
135
+ profile = UserProfile.objects.get(user=admin_user)
136
+ assert profile.handle is None
137
+
138
+ def test_edit_requires_login(self, client):
139
+ response = client.get(reverse("accounts:profile_edit"))
140
+ assert response.status_code == 302
141
+ assert "/auth/login/" in response.url
142
+
143
+
144
+@pytest.mark.django_db
145
+class TestPersonalAccessTokenCreate:
146
+ def test_create_form_renders(self, admin_client):
147
+ response = admin_client.get(reverse("accounts:profile_token_create"))
148
+ assert response.status_code == 200
149
+ assert b"Generate Personal Access Token" in response.content
150
+
151
+ def test_create_token_shows_raw_once(self, admin_client, admin_user):
152
+ response = admin_client.post(
153
+ reverse("accounts:profile_token_create"),
154
+ {"name": "CI Token", "scopes": "read,write"},
155
+ )
156
+ assert response.status_code == 200
157
+ assert b"frp_" in response.content
158
+ assert b"will not be shown again" in response.content
159
+ token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token")
160
+ assert token.scopes == "read,write"
161
+ assert token.token_prefix.startswith("frp_")
162
+
163
+ def test_create_token_default_scope_is_read(self, admin_client, admin_user):
164
+ admin_client.post(
165
+ reverse("accounts:profile_token_create"),
166
+ {"name": "Default Token", "scopes": ""},
167
+ )
168
+ token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token")
169
+ assert token.scopes == "read"
170
+
171
+ def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user):
172
+ admin_client.post(
173
+ reverse("accounts:profile_token_create"),
174
+ {"name": "Bad Token", "scopes": "delete,destroy"},
175
+ )
176
+ token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token")
177
+ assert token.scopes == "read" # falls back to read
178
+
179
+ def test_create_token_requires_name(self, admin_client, admin_user):
180
+ response = admin_client.post(
181
+ reverse("accounts:profile_token_create"),
182
+ {"name": "", "scopes": "read"},
183
+ )
184
+ assert response.status_code == 200
185
+ assert b"Token name is required" in response.content
186
+ assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0
187
+
188
+ def test_create_token_requires_login(self, client):
189
+ response = client.get(reverse("accounts:profile_token_create"))
190
+ assert response.status_code == 302
191
+ assert "/auth/login/" in response.url
192
+
193
+
194
+@pytest.mark.django_db
195
+class TestPersonalAccessTokenRevoke:
196
+ def test_revoke_token(self, admin_client, admin_user):
197
+ raw, token_hash, prefix = PersonalAccessToken.generate()
198
+ token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix)
199
+ response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
200
+ assert response.status_code == 302
201
+ token.refresh_from_db()
202
+ assert token.revoked_at is not None
203
+
204
+ def test_revoke_token_htmx(self, admin_client, admin_user):
205
+ raw, token_hash, prefix = PersonalAccessToken.generate()
206
+ PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix)
207
+ response = admin_client.post(
208
+ reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}),
209
+ HTTP_HX_REQUEST="true",
210
+ )
211
+ assert response.status_code == 200
212
+ assert response["HX-Redirect"] == "/auth/profile/"
213
+
214
+ def test_revoke_token_wrong_user(self, admin_client, viewer_user):
215
+ """Cannot revoke another user's token."""
216
+ raw, token_hash, prefix = PersonalAccessToken.generate()
217
+ PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix)
218
+ response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
219
+ assert response.status_code == 404
220
+
221
+ def test_revoke_already_revoked(self, admin_client, admin_user):
222
+ from django.utils import timezone
223
+
224
+ raw, token_hash, prefix = PersonalAccessToken.generate()
225
+ PersonalAccessToken.objects.create(
226
+ user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now()
227
+ )
228
+ response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
229
+ assert response.status_code == 404
230
+
231
+ def test_revoke_requires_post(self, admin_client, admin_user):
232
+ raw, token_hash, prefix = PersonalAccessToken.generate()
233
+ PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix)
234
+ response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
235
+ assert response.status_code == 405
236
+
237
+ def test_revoke_requires_login(self, client):
238
+ response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"}))
239
+ assert response.status_code == 302
240
+ assert "/auth/login/" in response.url
241
+
242
+
243
+# ---------------------------------------------------------------------------
244
+# Model unit tests
245
+# ---------------------------------------------------------------------------
246
+
247
+
248
+@pytest.mark.django_db
249
+class TestUserProfileModel:
250
+ def test_str_with_handle(self, admin_user):
251
+ profile = UserProfile.objects.create(user=admin_user, handle="testhandle")
252
+ assert str(profile) == "@testhandle"
253
+
254
+ def test_str_without_handle(self, admin_user):
255
+ profile = UserProfile.objects.create(user=admin_user)
256
+ assert str(profile) == "@admin"
257
+
258
+ def test_sanitize_handle(self):
259
+ assert UserProfile.sanitize_handle("Hello World!") == "helloworld"
260
+ assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle"
261
+ assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase"
262
+ assert UserProfile.sanitize_handle("") == ""
263
+
264
+ def test_multiple_null_handles_allowed(self, admin_user, viewer_user):
265
+ """Multiple profiles with handle=None should not violate unique constraint."""
266
+ UserProfile.objects.create(user=admin_user, handle=None)
267
+ UserProfile.objects.create(user=viewer_user, handle=None)
268
+ assert UserProfile.objects.filter(handle__isnull=True).count() == 2
269
+
270
+
271
+@pytest.mark.django_db
272
+class TestPersonalAccessTokenModel:
273
+ def test_generate_returns_triple(self):
274
+ raw, hash_val, prefix = PersonalAccessToken.generate()
275
+ assert raw.startswith("frp_")
276
+ assert len(hash_val) == 64
277
+ assert prefix == raw[:12]
278
+
279
+ def test_hash_token_matches_generate(self):
280
+ raw, expected_hash, _ = PersonalAccessToken.generate()
281
+ assert PersonalAccessToken.hash_token(raw) == expected_hash
282
+
283
+ def test_is_expired(self, admin_user):
284
+ from django.utils import timezone
285
+
286
+ token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1))
287
+ assert token.is_expired is True
288
+
289
+ def test_is_not_expired(self, admin_user):
290
+ from django.utils import timezone
291
+
292
+ token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1))
293
+ assert token.is_expired is False
294
+
295
+ def test_is_active(self, admin_user):
296
+ token = PersonalAccessToken(user=admin_user)
297
+ assert token.is_active is True
298
+
299
+ def test_is_revoked(self, admin_user):
300
+ from django.utils import timezone
301
+
302
+ token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now())
303
+ assert token.is_active is False
304
+ assert token.is_revoked is True
305
+
306
+ def test_str(self, admin_user):
307
+ token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345")
308
+ assert str(token) == "Test (frp_abc12345...)"
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -0,0 +1,308 @@
1 import pytest
2 from django.urls import reverse
3
4 from accounts.models import PersonalAccessToken, UserProfile
5
6
7 @pytest.mark.django_db
8 class TestLogin:
9 def test_login_page_renders(self, client):
10 response = client.get(reverse("accounts:login"))
11 assert response.status_code == 200
12 assert b"Sign in" in response.content
13
14 def test_login_success_redirects_to_dashboard(self, client, admin_user):
15 response = client.post(reverse("accounts:login"), {"username": "admin", "password": "testpass123"})
16 assert response.status_code == 302
17 assert response.url == reverse("dashboard")
18
19 def test_login_failure_shows_error(self, client, admin_user):
20 response = client.post(reverse("accounts:login"), {"username": "admin", "password": "wrong"})
21 assert response.status_code == 200
22 assert b"Invalid username or password" in response.content
23
24 def test_login_redirect_when_already_authenticated(self, admin_client):
25 response = admin_client.get(reverse("accounts:login"))
26 assert response.status_code == 302
27
28 def test_login_with_next_param(self, client, admin_user):
29 response = client.post(reverse("accounts:login") + "?next=/projects/", {"username": "admin", "password": "testpass123"})
30 assert response.status_code == 302
31 assert response.url == "/projects/"
32
33
34 @pytest.mark.django_db
35 class TestLogout:
36 def test_logout_redirects_to_login(self, admin_client):
37 response = admin_client.post(reverse("accounts:logout"))
38 assert response.status_code == 302
39 assert reverse("accounts:login") in response.url
40
41 def test_logout_clears_session(self, admin_client):
42 admin_client.post(reverse("accounts:logout"))
43 response = admin_client.get(reverse("dashboard"))
44 assert response.status_code == 302 # redirected to login
45
46 def test_logout_rejects_get(self, admin_client):
47 response = admin_client.get(reverse("accounts:logout"))
48 assert response.status_code == 405
49
50
51 # ---------------------------------------------------------------------------
52 # Profile views
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.mark.django_db
57 class TestProfile:
58 def test_profile_page_renders(self, admin_client, admin_user):
59 response = admin_client.get(reverse("accounts:profile"))
60 assert response.status_code == 200
61 assert b"Profile Info" in response.content
62 assert b"SSH Keys" in response.content
63 assert b"Personal Access Tokens" in response.content
64
65 def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user):
66 assert not UserProfile.objects.filter(user=admin_user).exists()
67 admin_client.get(reverse("accounts:profile"))
68 assert UserProfile.objects.filter(user=admin_user).exists()
69
70 def test_profile_requires_login(self, client):
71 response = client.get(reverse("accounts:profile"))
72 assert response.status_code == 302
73 assert "/auth/login/" in response.url
74
75 def test_profile_top_level_redirect(self, admin_client):
76 response = admin_client.get("/profile/")
77 assert response.status_code == 302
78 assert "/auth/profile/" in response.url
79
80
81 @pytest.mark.django_db
82 class TestProfileEdit:
83 def test_edit_page_renders(self, admin_client, admin_user):
84 response = admin_client.get(reverse("accounts:profile_edit"))
85 assert response.status_code == 200
86 assert b"Edit Profile" in response.content
87
88 def test_edit_updates_user_fields(self, admin_client, admin_user):
89 response = admin_client.post(
90 reverse("accounts:profile_edit"),
91 {
92 "first_name": "Alice",
93 "last_name": "Smith",
94 "email": "[email protected]",
95 "handle": "alice-s",
96 "bio": "Hello world",
97 "location": "NYC",
98 "website": "https://alice.dev",
99 },
100 )
101 assert response.status_code == 302
102 admin_user.refresh_from_db()
103 assert admin_user.first_name == "Alice"
104 assert admin_user.last_name == "Smith"
105 assert admin_user.email == "[email protected]"
106 profile = UserProfile.objects.get(user=admin_user)
107 assert profile.handle == "alice-s"
108 assert profile.bio == "Hello world"
109 assert profile.location == "NYC"
110 assert profile.website == "https://alice.dev"
111
112 def test_edit_sanitizes_handle(self, admin_client, admin_user):
113 admin_client.post(
114 reverse("accounts:profile_edit"),
115 {"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""},
116 )
117 profile = UserProfile.objects.get(user=admin_user)
118 assert profile.handle == "uppercasestuff"
119
120 def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user):
121 # Create a profile with handle for viewer_user
122 UserProfile.objects.create(user=viewer_user, handle="taken-handle")
123 response = admin_client.post(
124 reverse("accounts:profile_edit"),
125 {"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""},
126 )
127 assert response.status_code == 200 # re-renders form with error
128 assert b"already taken" in response.content
129
130 def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user):
131 admin_client.post(
132 reverse("accounts:profile_edit"),
133 {"handle": "", "first_name": "", "last_name": "", "email": ""},
134 )
135 profile = UserProfile.objects.get(user=admin_user)
136 assert profile.handle is None
137
138 def test_edit_requires_login(self, client):
139 response = client.get(reverse("accounts:profile_edit"))
140 assert response.status_code == 302
141 assert "/auth/login/" in response.url
142
143
144 @pytest.mark.django_db
145 class TestPersonalAccessTokenCreate:
146 def test_create_form_renders(self, admin_client):
147 response = admin_client.get(reverse("accounts:profile_token_create"))
148 assert response.status_code == 200
149 assert b"Generate Personal Access Token" in response.content
150
151 def test_create_token_shows_raw_once(self, admin_client, admin_user):
152 response = admin_client.post(
153 reverse("accounts:profile_token_create"),
154 {"name": "CI Token", "scopes": "read,write"},
155 )
156 assert response.status_code == 200
157 assert b"frp_" in response.content
158 assert b"will not be shown again" in response.content
159 token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token")
160 assert token.scopes == "read,write"
161 assert token.token_prefix.startswith("frp_")
162
163 def test_create_token_default_scope_is_read(self, admin_client, admin_user):
164 admin_client.post(
165 reverse("accounts:profile_token_create"),
166 {"name": "Default Token", "scopes": ""},
167 )
168 token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token")
169 assert token.scopes == "read"
170
171 def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user):
172 admin_client.post(
173 reverse("accounts:profile_token_create"),
174 {"name": "Bad Token", "scopes": "delete,destroy"},
175 )
176 token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token")
177 assert token.scopes == "read" # falls back to read
178
179 def test_create_token_requires_name(self, admin_client, admin_user):
180 response = admin_client.post(
181 reverse("accounts:profile_token_create"),
182 {"name": "", "scopes": "read"},
183 )
184 assert response.status_code == 200
185 assert b"Token name is required" in response.content
186 assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0
187
188 def test_create_token_requires_login(self, client):
189 response = client.get(reverse("accounts:profile_token_create"))
190 assert response.status_code == 302
191 assert "/auth/login/" in response.url
192
193
194 @pytest.mark.django_db
195 class TestPersonalAccessTokenRevoke:
196 def test_revoke_token(self, admin_client, admin_user):
197 raw, token_hash, prefix = PersonalAccessToken.generate()
198 token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix)
199 response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
200 assert response.status_code == 302
201 token.refresh_from_db()
202 assert token.revoked_at is not None
203
204 def test_revoke_token_htmx(self, admin_client, admin_user):
205 raw, token_hash, prefix = PersonalAccessToken.generate()
206 PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix)
207 response = admin_client.post(
208 reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}),
209 HTTP_HX_REQUEST="true",
210 )
211 assert response.status_code == 200
212 assert response["HX-Redirect"] == "/auth/profile/"
213
214 def test_revoke_token_wrong_user(self, admin_client, viewer_user):
215 """Cannot revoke another user's token."""
216 raw, token_hash, prefix = PersonalAccessToken.generate()
217 PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix)
218 response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
219 assert response.status_code == 404
220
221 def test_revoke_already_revoked(self, admin_client, admin_user):
222 from django.utils import timezone
223
224 raw, token_hash, prefix = PersonalAccessToken.generate()
225 PersonalAccessToken.objects.create(
226 user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now()
227 )
228 response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
229 assert response.status_code == 404
230
231 def test_revoke_requires_post(self, admin_client, admin_user):
232 raw, token_hash, prefix = PersonalAccessToken.generate()
233 PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix)
234 response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
235 assert response.status_code == 405
236
237 def test_revoke_requires_login(self, client):
238 response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"}))
239 assert response.status_code == 302
240 assert "/auth/login/" in response.url
241
242
243 # ---------------------------------------------------------------------------
244 # Model unit tests
245 # ---------------------------------------------------------------------------
246
247
248 @pytest.mark.django_db
249 class TestUserProfileModel:
250 def test_str_with_handle(self, admin_user):
251 profile = UserProfile.objects.create(user=admin_user, handle="testhandle")
252 assert str(profile) == "@testhandle"
253
254 def test_str_without_handle(self, admin_user):
255 profile = UserProfile.objects.create(user=admin_user)
256 assert str(profile) == "@admin"
257
258 def test_sanitize_handle(self):
259 assert UserProfile.sanitize_handle("Hello World!") == "helloworld"
260 assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle"
261 assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase"
262 assert UserProfile.sanitize_handle("") == ""
263
264 def test_multiple_null_handles_allowed(self, admin_user, viewer_user):
265 """Multiple profiles with handle=None should not violate unique constraint."""
266 UserProfile.objects.create(user=admin_user, handle=None)
267 UserProfile.objects.create(user=viewer_user, handle=None)
268 assert UserProfile.objects.filter(handle__isnull=True).count() == 2
269
270
271 @pytest.mark.django_db
272 class TestPersonalAccessTokenModel:
273 def test_generate_returns_triple(self):
274 raw, hash_val, prefix = PersonalAccessToken.generate()
275 assert raw.startswith("frp_")
276 assert len(hash_val) == 64
277 assert prefix == raw[:12]
278
279 def test_hash_token_matches_generate(self):
280 raw, expected_hash, _ = PersonalAccessToken.generate()
281 assert PersonalAccessToken.hash_token(raw) == expected_hash
282
283 def test_is_expired(self, admin_user):
284 from django.utils import timezone
285
286 token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1))
287 assert token.is_expired is True
288
289 def test_is_not_expired(self, admin_user):
290 from django.utils import timezone
291
292 token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1))
293 assert token.is_expired is False
294
295 def test_is_active(self, admin_user):
296 token = PersonalAccessToken(user=admin_user)
297 assert token.is_active is True
298
299 def test_is_revoked(self, admin_user):
300 from django.utils import timezone
301
302 token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now())
303 assert token.is_active is False
304 assert token.is_revoked is True
305
306 def test_str(self, admin_user):
307 token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345")
308 assert str(token) == "Test (frp_abc12345...)"
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -0,0 +1,18 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "accounts"
6
+
7
+urlpatterns = [
8
+ path("login/", views.login_view, name="login"),
9
+ path("logout/", views.logout_view, name="logout"),
10
+ path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
11
+ path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
12
+ path("notifications/", views.notification_preferences, name="notification_prefs"),
13
+ # Unified profile
14
+ path("profile/", views.profile, name="profile"),
15
+ path("profile/edit/", views.profile_edit, name="profile_edit"),
16
+ path("profile/tokens/create/", views.profile_token_create, name="profile_token_create"),
17
+ path("profile/tokens/<str:guid>/revoke/", views.profile_token_revoke, name="profile_token_revoke"),
18
+]
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -0,0 +1,18 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "accounts"
6
7 urlpatterns = [
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
11 path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
12 path("notifications/", views.notification_preferences, name="notification_prefs"),
13 # Unified profile
14 path("profile/", views.profile, name="profile"),
15 path("profile/edit/", views.profile_edit, name="profile_edit"),
16 path("profile/tokens/create/", views.profile_token_create, name="profile_token_create"),
17 path("profile/tokens/<str:guid>/revoke/", views.profile_token_revoke, name="profile_token_revoke"),
18 ]
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -0,0 +1,287 @@
1
+import re
2
+
3
+from django.contribequests
4
+from django.contrib import messages
5
+from django.contrib.auth import login, logout
6
+from django.contrib.auth.decorators import login_required
7
+from django.http import HttpResponse
8
+from django.shortcuts import get_object_or_404, redirect, render
9
+from django.utils.http import url_has_allowed_host_and_scheme
10
+from django.views.decorators.http import require_POST
11
+from django_ratelimit.decorators import ratelimit
12
+
13
+from .forms import LoginForm
14
+from .models import Personagging.getLogger(__name__)
15
+
16
+# Allowed SSH key type prefixes
17
+_SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
18
+
19
+
20
+def _sanitize_ssh_key(public_key: str) -> tuple[str | None, str]:
21
+ """Validate and sanitize an SSH public key.
22
+
23
+ Returns (sanitized_key, error_message). On success error_message is "".
24
+ Rejects keys containing newlines, carriage returns, or null bytes (which
25
+ would allow injection of extra authorized_keys entries). Validates format:
26
+ known type prefix, 2-3 space-separated parts.
27
+ """
28
+ # Strip dangerous injection characters -- newlines let an attacker add
29
+ # a second authorized_keys line outside the forced-command wrapper
30
+ if "\n" in public_key or "\r" in public_key or "\x00" in public_key:
31
+ return None, "SSH key must be a single line. Newlines, carriage returns, and null bytes are not allowed."
32
+
33
+ key = public_key.strip()
34
+ if not key:
35
+ return None, "SSH key cannot be empty."
36
+
37
+ # SSH keys are: <type> <base64-data> [optional comment]
38
+ parts = key.split()
39
+ if len(parts) < 2 or len(parts) > 3:
40
+ return None, "Invalid SSH key format. Expected: <key-type> <key-data> [comment]"
41
+
42
+ key_type = parts[0]
43
+ if not any(key_type.startswith(prefix) for prefix in _SSH_KEY_PREFIXES):
44
+ return None, f"Unsupported key type '{key_type}'. Allowed: ssh-ed25519, ssh-rsa, ecdsa-sha2-*, ssh-dss."
45
+
46
+ # Validate base64 data is plausible (only base64 chars + padding)
47
+ if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
48
+ return None, "Invalid SSH key data encodifix, 2-3 space-separated parts.
49
+ """
50
+ # Strip dangerous injection characters -- newlines let an attacker add
51
+ # a second authorized_keys line if requesteturn key, ""
52
+
53
+
54
+def _verify_turnstile(token: str, remote_ip: str) -> bool:
55
+ """Verify a Cloudflare Turnstile response token. Returns True if valid."""
56
+ from constance import config
57
+
58
+ if not config.TURNSTILE_SECRET_KEY:
59
+ return False
60
+ try:
61
+ resp = requests.post(
62
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
63
+ data={"secret": config.TURNreturn render(request, "accounts/login.html", {"form": form})
64
+
65
+
66
+@require_POST
67
+def logout_view(request):
68
+ logout(request)
69
+ return redirect("accounts:login")
70
+
71
+
72
+# ---------------------------------------------------------------------------
73
+# SSH key management
74
+# ---------------------------------------------------------------------------
75
+
76
+
77
+def _parse_key_type(public_key):
78
+ """Extract key type from public key string."""
79
+ parts = public_key.strip().split()
80
+ if parts:
81
+ key_prefix = parts[0]
82
+ type_map = {
83
+ "ssh-ed25519": "ed25519",
84
+ "ssh-rsa": "rsa",
85
+ "ecdsa-sha2-nistp256": "ecdsa",
86
+ "ecdsa-sha2-nistp384": "ecdsa",
87
+ "ecdsa-sha2-nistp521": "ecdsa",
88
+ "ssh-dss": "dsa",
89
+ }
90
+ return type_map.get(key_prefix, key_prefix)
91
+ return ""
92
+
93
+
94
+def _compute_fingerprint(public_key):
95
+ """Compute SSH key fingerprint (SHA256)."""
96
+ import base64
97
+ import hashlib
98
+
99
+ parts = public_key.strip().split()
100
+ if len(parts) >= 2:
101
+ try:
102
+ key_data = base64.b64decode(parts[1])
103
+ digest = hashlib.sha256(key_data).digest()
104
+ return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode()
105
+ except Exception:
106
+ pass
107
+ return ""
108
+
109
+
110
+def _regenerate_authorized_keys():
111
+ """Regenerate the authorized_keys file from all active user SSH keys."""
112
+ from pathlib import Path
113
+
114
+ from constance import config
115
+
116
+ from fossil.user_keys import UserSSHKey
117
+
118
+ ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh"
119
+ ssh_dir.mkdir(parents=True, exist_ok=True)
120
+ authorized_keys_path = ssh_dir / "authorized_keys"
121
+
122
+ keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user")
123
+
124
+ lines = []
125
+ for key in keys:
126
+ # Defense in depth: strip newlines/CR/null from stored keys so a
127
+ # compromised DB value cannot inject extra authorized_keys entries.
128
+ clean_key = key.public_key.strip().replace("\n", "").replace("\r", "").replace("\x00", "")
129
+ if not clean_key:
130
+ continue
131
+ # Each key gets a forced command that identifies the user
132
+ forced_cmd = (
133
+ f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
134
+ )
135
+ lines.append(f"{forced_cmd} {clean_key}")
136
+
137
+ authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "")
138
+ authorized_keys_path.chmod(0o600)
139
+
140
+
141
+@login_required
142
+def ssh_keys(request):
143
+ """List and add SSH keys."""
144
+ from fossil.user_keys import UserSSHKey
145
+
146
+ keys = UserSSHKey.objects.filter(user=request.user)
147
+
148
+ if request.method == "POST":
149
+ title = request.POST.get("title", "").strip()
150
+ public_key = request.POST.get("public_key", "").strip()
151
+
152
+ if title and public_key:
153
+ sanitized_key, error = _sanitize_ssh_key(public_key)
154
+ if error:
155
+ messages.error(request, error)
156
+ return render(request, "accounts/ssh_keys.html", {"keys": keys})
157
+
158
+ key_type = _parse_key_type(sanitized_key)
159
+ fingerprint = _compute_fingerprint(sanitized_key)
160
+
161
+ UserSSHKey.objects.create(
162
+ user=request.user,
163
+ title=title,
164
+ public_key=sanitized_key,
165
+ key_type=key_type,
166
+ fingerprint=fingerprint,
167
+ created_by=request.user,
168
+ )
169
+
170
+ _regenerate_authorized_keys()
171
+
172
+ messages.success(request, f'SSH key "{title}" added.')
173
+ return redirect("accounts:ssh_keys")
174
+
175
+ return render(request, "accounts/ssh_keys.html", {"keys": keys})
176
+
177
+
178
+@login_required
179
+@require_POST
180
+def ssh_key_delete(request, pk):
181
+ """Delete an SSH key."""
182
+ from fossil.user_keys import UserSSHKey
183
+
184
+ key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
185
+ key.soft_delete(user=request.user)
186
+ _regenerate_authorized_keys()
187
+
188
+ messages.success(request, f'SSH key "{key.title}" removed.')
189
+
190
+ if request.headers.get("HX-Request"):
191
+ return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"})
192
+
193
+ return redirect("accounts:ssh_keys")
194
+
195
+
196
+@login_required
197
+def notification_preferences(request):
198
+ """User notification preferences page."""
199
+ from fossil.notifications import NotificationPreference
200
+
201
+ prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
202
+
203
+ if request.method == "POST":
204
+ prefs.delivery_mode = request.POST.get("delivery_mode", "immediate")
205
+ prefs.notify_checkins = "notify_checkins" in request.POST
206
+ prefs.notify_tickets = "notify_tickets" in request.POST
207
+ prefs.notify_wiki = "notify_wiki" in request.POST
208
+ prefs.notify_releases = "notify_releases" in request.POST
209
+ prefs.notify_forum = "notify_forum" in request.POST
210
+ prefs.save()
211
+
212
+ messages.success(request, "Notification preferences updated.")
213
+
214
+ if request.headers.get("HX-Request"):
215
+ return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
216
+
217
+ return redirect("accounts:notification_prefs")
218
+
219
+ return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
220
+
221
+
222
+# ---------------------------------------------------------------------------
223
+# Unified profile
224
+# ---------------------------------------------------------------------------
225
+
226
+VALID_SCOPES = {"read", "write", "admin"}
227
+
228
+
229
+@login_required
230
+def profile(request):
231
+ """Unified user profile page consolidating all personal settings."""
232
+ from fossil.notifications import NotificationPreference
233
+ from fossil.user_keys import UserSSHKey
234
+
235
+ user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
236
+ notif_prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
237
+ ssh_keys = UserSSHKey.objects.filter(user=request.user)
238
+ tokens = PersonalAccessToken.objects.filter(user=request.user, revoked_at__isnull=True)
239
+
240
+ return render(
241
+ request,
242
+ "accounts/profile.html",
243
+ {
244
+ "user_profile": user_profile,
245
+ "notif_prefs": notif_prefs,
246
+ "ssh_keys": ssh_keys,
247
+ "tokens": tokens,
248
+ },
249
+ )
250
+
251
+
252
+@login_required
253
+def profile_edit(request):
254
+ """Edit profile info: name, email, handle, bio, location, website."""
255
+ user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
256
+
257
+ if request.method == "POST":
258
+ user = request.user
259
+ user.first_name = request.POST.get("first_name", "").strip()[:30]
260
+ user.last_name = request.POST.get("last_name", "").strip()[:150]
261
+ user.email = request.POST.get("email", "").strip()[:254]
262
+ user.save(update_fields=["first_name", "last_name", "email"])
263
+
264
+ raw_handle = request.POST.get("handle", "").strip()
265
+ handle = UserProfile.sanitize_handle(raw_handle)
266
+ if handle:
267
+ # Check uniqueness (excluding self)
268
+ conflict = UserProfile.objects.filter(handle=handle).exclude(pk=user_profile.pk).exists()
269
+ if conflict:
270
+ messages.error(request, f"Handle @{handle} is already taken.")
271
+ return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
272
+ user_profile.handle = handle
273
+ else:
274
+ user_profile.handle = None
275
+
276
+ user_profile.bio = request.POST.get("bio", "").strip()[:500]
277
+ user_profile.location = request.POST.get("location", "").strip()[:100]
278
+ user_profile.website = request.POST.get("website", "").strip()[:200]
279
+ user_profile.save()
280
+
281
+ messages.success(request, "Profile updated.")
282
+ return redirect("accounts:profile")
283
+
284
+ return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
285
+
286
+
287
+@login_requi
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -0,0 +1,287 @@
1 import re
2
3 from django.contribequests
4 from django.contrib import messages
5 from django.contrib.auth import login, logout
6 from django.contrib.auth.decorators import login_required
7 from django.http import HttpResponse
8 from django.shortcuts import get_object_or_404, redirect, render
9 from django.utils.http import url_has_allowed_host_and_scheme
10 from django.views.decorators.http import require_POST
11 from django_ratelimit.decorators import ratelimit
12
13 from .forms import LoginForm
14 from .models import Personagging.getLogger(__name__)
15
16 # Allowed SSH key type prefixes
17 _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
18
19
20 def _sanitize_ssh_key(public_key: str) -> tuple[str | None, str]:
21 """Validate and sanitize an SSH public key.
22
23 Returns (sanitized_key, error_message). On success error_message is "".
24 Rejects keys containing newlines, carriage returns, or null bytes (which
25 would allow injection of extra authorized_keys entries). Validates format:
26 known type prefix, 2-3 space-separated parts.
27 """
28 # Strip dangerous injection characters -- newlines let an attacker add
29 # a second authorized_keys line outside the forced-command wrapper
30 if "\n" in public_key or "\r" in public_key or "\x00" in public_key:
31 return None, "SSH key must be a single line. Newlines, carriage returns, and null bytes are not allowed."
32
33 key = public_key.strip()
34 if not key:
35 return None, "SSH key cannot be empty."
36
37 # SSH keys are: <type> <base64-data> [optional comment]
38 parts = key.split()
39 if len(parts) < 2 or len(parts) > 3:
40 return None, "Invalid SSH key format. Expected: <key-type> <key-data> [comment]"
41
42 key_type = parts[0]
43 if not any(key_type.startswith(prefix) for prefix in _SSH_KEY_PREFIXES):
44 return None, f"Unsupported key type '{key_type}'. Allowed: ssh-ed25519, ssh-rsa, ecdsa-sha2-*, ssh-dss."
45
46 # Validate base64 data is plausible (only base64 chars + padding)
47 if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
48 return None, "Invalid SSH key data encodifix, 2-3 space-separated parts.
49 """
50 # Strip dangerous injection characters -- newlines let an attacker add
51 # a second authorized_keys line if requesteturn key, ""
52
53
54 def _verify_turnstile(token: str, remote_ip: str) -> bool:
55 """Verify a Cloudflare Turnstile response token. Returns True if valid."""
56 from constance import config
57
58 if not config.TURNSTILE_SECRET_KEY:
59 return False
60 try:
61 resp = requests.post(
62 "https://challenges.cloudflare.com/turnstile/v0/siteverify",
63 data={"secret": config.TURNreturn render(request, "accounts/login.html", {"form": form})
64
65
66 @require_POST
67 def logout_view(request):
68 logout(request)
69 return redirect("accounts:login")
70
71
72 # ---------------------------------------------------------------------------
73 # SSH key management
74 # ---------------------------------------------------------------------------
75
76
77 def _parse_key_type(public_key):
78 """Extract key type from public key string."""
79 parts = public_key.strip().split()
80 if parts:
81 key_prefix = parts[0]
82 type_map = {
83 "ssh-ed25519": "ed25519",
84 "ssh-rsa": "rsa",
85 "ecdsa-sha2-nistp256": "ecdsa",
86 "ecdsa-sha2-nistp384": "ecdsa",
87 "ecdsa-sha2-nistp521": "ecdsa",
88 "ssh-dss": "dsa",
89 }
90 return type_map.get(key_prefix, key_prefix)
91 return ""
92
93
94 def _compute_fingerprint(public_key):
95 """Compute SSH key fingerprint (SHA256)."""
96 import base64
97 import hashlib
98
99 parts = public_key.strip().split()
100 if len(parts) >= 2:
101 try:
102 key_data = base64.b64decode(parts[1])
103 digest = hashlib.sha256(key_data).digest()
104 return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode()
105 except Exception:
106 pass
107 return ""
108
109
110 def _regenerate_authorized_keys():
111 """Regenerate the authorized_keys file from all active user SSH keys."""
112 from pathlib import Path
113
114 from constance import config
115
116 from fossil.user_keys import UserSSHKey
117
118 ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh"
119 ssh_dir.mkdir(parents=True, exist_ok=True)
120 authorized_keys_path = ssh_dir / "authorized_keys"
121
122 keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user")
123
124 lines = []
125 for key in keys:
126 # Defense in depth: strip newlines/CR/null from stored keys so a
127 # compromised DB value cannot inject extra authorized_keys entries.
128 clean_key = key.public_key.strip().replace("\n", "").replace("\r", "").replace("\x00", "")
129 if not clean_key:
130 continue
131 # Each key gets a forced command that identifies the user
132 forced_cmd = (
133 f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
134 )
135 lines.append(f"{forced_cmd} {clean_key}")
136
137 authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "")
138 authorized_keys_path.chmod(0o600)
139
140
141 @login_required
142 def ssh_keys(request):
143 """List and add SSH keys."""
144 from fossil.user_keys import UserSSHKey
145
146 keys = UserSSHKey.objects.filter(user=request.user)
147
148 if request.method == "POST":
149 title = request.POST.get("title", "").strip()
150 public_key = request.POST.get("public_key", "").strip()
151
152 if title and public_key:
153 sanitized_key, error = _sanitize_ssh_key(public_key)
154 if error:
155 messages.error(request, error)
156 return render(request, "accounts/ssh_keys.html", {"keys": keys})
157
158 key_type = _parse_key_type(sanitized_key)
159 fingerprint = _compute_fingerprint(sanitized_key)
160
161 UserSSHKey.objects.create(
162 user=request.user,
163 title=title,
164 public_key=sanitized_key,
165 key_type=key_type,
166 fingerprint=fingerprint,
167 created_by=request.user,
168 )
169
170 _regenerate_authorized_keys()
171
172 messages.success(request, f'SSH key "{title}" added.')
173 return redirect("accounts:ssh_keys")
174
175 return render(request, "accounts/ssh_keys.html", {"keys": keys})
176
177
178 @login_required
179 @require_POST
180 def ssh_key_delete(request, pk):
181 """Delete an SSH key."""
182 from fossil.user_keys import UserSSHKey
183
184 key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
185 key.soft_delete(user=request.user)
186 _regenerate_authorized_keys()
187
188 messages.success(request, f'SSH key "{key.title}" removed.')
189
190 if request.headers.get("HX-Request"):
191 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"})
192
193 return redirect("accounts:ssh_keys")
194
195
196 @login_required
197 def notification_preferences(request):
198 """User notification preferences page."""
199 from fossil.notifications import NotificationPreference
200
201 prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
202
203 if request.method == "POST":
204 prefs.delivery_mode = request.POST.get("delivery_mode", "immediate")
205 prefs.notify_checkins = "notify_checkins" in request.POST
206 prefs.notify_tickets = "notify_tickets" in request.POST
207 prefs.notify_wiki = "notify_wiki" in request.POST
208 prefs.notify_releases = "notify_releases" in request.POST
209 prefs.notify_forum = "notify_forum" in request.POST
210 prefs.save()
211
212 messages.success(request, "Notification preferences updated.")
213
214 if request.headers.get("HX-Request"):
215 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
216
217 return redirect("accounts:notification_prefs")
218
219 return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
220
221
222 # ---------------------------------------------------------------------------
223 # Unified profile
224 # ---------------------------------------------------------------------------
225
226 VALID_SCOPES = {"read", "write", "admin"}
227
228
229 @login_required
230 def profile(request):
231 """Unified user profile page consolidating all personal settings."""
232 from fossil.notifications import NotificationPreference
233 from fossil.user_keys import UserSSHKey
234
235 user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
236 notif_prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
237 ssh_keys = UserSSHKey.objects.filter(user=request.user)
238 tokens = PersonalAccessToken.objects.filter(user=request.user, revoked_at__isnull=True)
239
240 return render(
241 request,
242 "accounts/profile.html",
243 {
244 "user_profile": user_profile,
245 "notif_prefs": notif_prefs,
246 "ssh_keys": ssh_keys,
247 "tokens": tokens,
248 },
249 )
250
251
252 @login_required
253 def profile_edit(request):
254 """Edit profile info: name, email, handle, bio, location, website."""
255 user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
256
257 if request.method == "POST":
258 user = request.user
259 user.first_name = request.POST.get("first_name", "").strip()[:30]
260 user.last_name = request.POST.get("last_name", "").strip()[:150]
261 user.email = request.POST.get("email", "").strip()[:254]
262 user.save(update_fields=["first_name", "last_name", "email"])
263
264 raw_handle = request.POST.get("handle", "").strip()
265 handle = UserProfile.sanitize_handle(raw_handle)
266 if handle:
267 # Check uniqueness (excluding self)
268 conflict = UserProfile.objects.filter(handle=handle).exclude(pk=user_profile.pk).exists()
269 if conflict:
270 messages.error(request, f"Handle @{handle} is already taken.")
271 return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
272 user_profile.handle = handle
273 else:
274 user_profile.handle = None
275
276 user_profile.bio = request.POST.get("bio", "").strip()[:500]
277 user_profile.location = request.POST.get("location", "").strip()[:100]
278 user_profile.website = request.POST.get("website", "").strip()[:200]
279 user_profile.save()
280
281 messages.success(request, "Profile updated.")
282 return redirect("accounts:profile")
283
284 return render(request, "accounts/profile_edit.html", {"user_profile": user_profile})
285
286
287 @login_requi
D auth1/__init__.py

No diff available

D auth1/apps.py
-7
--- a/auth1/apps.py
+++ b/auth1/apps.py
@@ -1,7 +0,0 @@
1
-from django.apps import AppConfig
2
-
3
-
4
-class Auth1Config(AppConfig):
5
- default_auto_field = "django.db.models.BigAutoField"
6
- name = "auth1"
7
- verbose_name = "Authentication"
--- a/auth1/apps.py
+++ b/auth1/apps.py
@@ -1,7 +0,0 @@
1 from django.apps import AppConfig
2
3
4 class Auth1Config(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "auth1"
7 verbose_name = "Authentication"
--- a/auth1/apps.py
+++ b/auth1/apps.py
@@ -1,7 +0,0 @@
 
 
 
 
 
 
 
D auth1/forms.py
-22
--- a/auth1/forms.py
+++ b/auth1/forms.py
@@ -1,22 +0,0 @@
1
-from django import forms
2
-from django.contrib.auth.forms import AuthenticationForm
3
-
4
-
5
-class LoginForm(AuthenticationForm):
6
- username = forms.CharField(
7
- widget=forms.TextInput(
8
- attrs={
9
- "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10
- "placeholder": "Username",
11
- "autofocus": True,
12
- }
13
- )
14
- )
15
- password = forms.CharField(
16
- widget=forms.PasswordInput(
17
- attrs={
18
- "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19
- "placeholder": "Password",
20
- }
21
- )
22
- )
--- a/auth1/forms.py
+++ b/auth1/forms.py
@@ -1,22 +0,0 @@
1 from django import forms
2 from django.contrib.auth.forms import AuthenticationForm
3
4
5 class LoginForm(AuthenticationForm):
6 username = forms.CharField(
7 widget=forms.TextInput(
8 attrs={
9 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10 "placeholder": "Username",
11 "autofocus": True,
12 }
13 )
14 )
15 password = forms.CharField(
16 widget=forms.PasswordInput(
17 attrs={
18 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19 "placeholder": "Password",
20 }
21 )
22 )
--- a/auth1/forms.py
+++ b/auth1/forms.py
@@ -1,22 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
D auth1/migrations/__init__.py

No diff available

D auth1/tests.py
-46
--- a/auth1/tests.py
+++ b/auth1/tests.py
@@ -1,46 +0,0 @@
1
-import pytest
2
-from django.urls import reverse
3
-
4
-
5
-@pytest.mark.django_db
6
-class TestLogin:
7
- def test_login_page_renders(self, client):
8
- response = client.get(reverse("auth1:login"))
9
- assert response.status_code == 200
10
- assert b"Sign in" in response.content
11
-
12
- def test_login_success_redirects_to_dashboard(self, client, admin_user):
13
- response = client.post(reverse("auth1:login"), {"username": "admin", "password": "testpass123"})
14
- assert response.status_code == 302
15
- assert response.url == reverse("dashboard")
16
-
17
- def test_login_failure_shows_error(self, client, admin_user):
18
- response = client.post(reverse("auth1:login"), {"username": "admin", "password": "wrong"})
19
- assert response.status_code == 200
20
- assert b"Invalid username or password" in response.content
21
-
22
- def test_login_redirect_when_already_authenticated(self, admin_client):
23
- response = admin_client.get(reverse("auth1:login"))
24
- assert response.status_code == 302
25
-
26
- def test_login_with_next_param(self, client, admin_user):
27
- response = client.post(reverse("auth1:login") + "?next=/items/", {"username": "admin", "password": "testpass123"})
28
- assert response.status_code == 302
29
- assert response.url == "/items/"
30
-
31
-
32
-@pytest.mark.django_db
33
-class TestLogout:
34
- def test_logout_redirects_to_login(self, admin_client):
35
- response = admin_client.post(reverse("auth1:logout"))
36
- assert response.status_code == 302
37
- assert reverse("auth1:login") in response.url
38
-
39
- def test_logout_clears_session(self, admin_client):
40
- admin_client.post(reverse("auth1:logout"))
41
- response = admin_client.get(reverse("dashboard"))
42
- assert response.status_code == 302 # redirected to login
43
-
44
- def test_logout_rejects_get(self, admin_client):
45
- response = admin_client.get(reverse("auth1:logout"))
46
- assert response.status_code == 405
--- a/auth1/tests.py
+++ b/auth1/tests.py
@@ -1,46 +0,0 @@
1 import pytest
2 from django.urls import reverse
3
4
5 @pytest.mark.django_db
6 class TestLogin:
7 def test_login_page_renders(self, client):
8 response = client.get(reverse("auth1:login"))
9 assert response.status_code == 200
10 assert b"Sign in" in response.content
11
12 def test_login_success_redirects_to_dashboard(self, client, admin_user):
13 response = client.post(reverse("auth1:login"), {"username": "admin", "password": "testpass123"})
14 assert response.status_code == 302
15 assert response.url == reverse("dashboard")
16
17 def test_login_failure_shows_error(self, client, admin_user):
18 response = client.post(reverse("auth1:login"), {"username": "admin", "password": "wrong"})
19 assert response.status_code == 200
20 assert b"Invalid username or password" in response.content
21
22 def test_login_redirect_when_already_authenticated(self, admin_client):
23 response = admin_client.get(reverse("auth1:login"))
24 assert response.status_code == 302
25
26 def test_login_with_next_param(self, client, admin_user):
27 response = client.post(reverse("auth1:login") + "?next=/items/", {"username": "admin", "password": "testpass123"})
28 assert response.status_code == 302
29 assert response.url == "/items/"
30
31
32 @pytest.mark.django_db
33 class TestLogout:
34 def test_logout_redirects_to_login(self, admin_client):
35 response = admin_client.post(reverse("auth1:logout"))
36 assert response.status_code == 302
37 assert reverse("auth1:login") in response.url
38
39 def test_logout_clears_session(self, admin_client):
40 admin_client.post(reverse("auth1:logout"))
41 response = admin_client.get(reverse("dashboard"))
42 assert response.status_code == 302 # redirected to login
43
44 def test_logout_rejects_get(self, admin_client):
45 response = admin_client.get(reverse("auth1:logout"))
46 assert response.status_code == 405
--- a/auth1/tests.py
+++ b/auth1/tests.py
@@ -1,46 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
D auth1/urls.py
-10
--- a/auth1/urls.py
+++ b/auth1/urls.py
@@ -1,10 +0,0 @@
1
-from django.urls import path
2
-
3
-from . import views
4
-
5
-app_name = "auth1"
6
-
7
-urlpatterns = [
8
- path("login/", views.login_view, name="login"),
9
- path("logout/", views.logout_view, name="logout"),
10
-]
--- a/auth1/urls.py
+++ b/auth1/urls.py
@@ -1,10 +0,0 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "auth1"
6
7 urlpatterns = [
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 ]
--- a/auth1/urls.py
+++ b/auth1/urls.py
@@ -1,10 +0,0 @@
 
 
 
 
 
 
 
 
 
 
D auth1/views.py
-29
--- a/auth1/views.py
+++ b/auth1/views.py
@@ -1,29 +0,0 @@
1
-from django.contribessages
2
-fromshortcuts import redirect, render
3
-from django.views.decorators.http import require_POST
4
-from django_ratelimit.decorators import ratelimit
5
-
6
-from .forms import LoginForm
7
-
8
-
9
-@ratelimit(key="ip", rate="10/m", block=True)
10
-def login_view(request):
11
- if request.user.is_authenticated:
12
- return redirect("dashboard")
13
-
14
- if request.method == "POST":
15
- form = LoginForm(request, data=request.POST)
16
- if form.is_valid():
17
- login(request, form.get_user())
18
- next_url = request.GET.get("next", "dashboard")
19
- return redirect(next_url)
20
- else:
21
- form = LoginForm()
22
-
23
- return render(request, "auth1/login.html", {"form": form})
24
-
25
-
26
-@require_POST
27
-def logout_view(request):
28
- logout(request)
29
- retu
--- a/auth1/views.py
+++ b/auth1/views.py
@@ -1,29 +0,0 @@
1 from django.contribessages
2 fromshortcuts import redirect, render
3 from django.views.decorators.http import require_POST
4 from django_ratelimit.decorators import ratelimit
5
6 from .forms import LoginForm
7
8
9 @ratelimit(key="ip", rate="10/m", block=True)
10 def login_view(request):
11 if request.user.is_authenticated:
12 return redirect("dashboard")
13
14 if request.method == "POST":
15 form = LoginForm(request, data=request.POST)
16 if form.is_valid():
17 login(request, form.get_user())
18 next_url = request.GET.get("next", "dashboard")
19 return redirect(next_url)
20 else:
21 form = LoginForm()
22
23 return render(request, "auth1/login.html", {"form": form})
24
25
26 @require_POST
27 def logout_view(request):
28 logout(request)
29 retu
--- a/auth1/views.py
+++ b/auth1/views.py
@@ -1,29 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
+30 -32
--- bootstrap.md
+++ bootstrap.md
@@ -74,13 +74,12 @@
7474
7575
```
7676
fossilrepo/
7777
|-- config/ # Django settings, URLs, Celery
7878
|-- core/ # Base models, permissions, middleware
79
-|-- auth1/ # Session-based auth
79
+|-- accounts/ # Session-based auth
8080
|-- organization/ # Org + member management
81
-|-- items/ # Example CRUD app (reference only)
8281
|-- docker/ # Fossil-specific: Caddyfile, litestream.yml
8382
|-- templates/ # HTMX templates
8483
|-- _old_fossilrepo/ # Original server/sync/cli code (being ported)
8584
+-- docs/ # Architecture guides
8685
```
@@ -89,19 +88,19 @@
8988
9089
## What's Already Built
9190
9291
| Layer | What's there |
9392
|---|---|
94
-| Auth | Session-based auth (auth1), login/logout views with templates, rate limiting |
93
+| Auth | Session-based auth (accounts), login/logout views with templates, rate limiting |
9594
| Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) |
9695
| API | Django views returning HTML (full pages + HTMX partials) |
9796
| Permissions | Group-based via `P` enum, checked in every view |
9897
| Async | Celery worker + beat, Redis broker |
9998
| Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) |
10099
| Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit |
101100
| CI | GitHub Actions: lint (Ruff) + tests (Postgres + Redis services) |
102
-| Seed | `python manage.py seed` creates admin/viewer users, sample items |
101
+| Seed | `python manage.py seed` creates admin/viewer users, sample data |
103102
| Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates |
104103
105104
---
106105
107106
## App Structure
@@ -108,13 +107,12 @@
108107
109108
| App | Purpose |
110109
|---|---|
111110
| `config` | Django settings, URLs, Celery configuration |
112111
| `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware |
113
-| `auth1` | Session-based authentication: login/logout views with rate limiting |
112
+| `accounts` | Session-based authentication: login/logout views with rate limiting |
114113
| `organization` | Organization + OrganizationMember models |
115
-| `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) |
116114
| `testdata` | `seed` management command for development data |
117115
118116
---
119117
120118
## Conventions
@@ -134,12 +132,12 @@
134132
135133
**`BaseCoreModel(Tracking)`** (abstract) -- named entities:
136134
```python
137135
from core.models import BaseCoreModel
138136
139
-class Item(BaseCoreModel):
140
- price = models.DecimalField(...)
137
+class Project(BaseCoreModel):
138
+ visibility = models.CharField(...)
141139
```
142140
Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
143141
144142
**Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
145143
@@ -151,28 +149,28 @@
151149
152150
Views return full pages for normal requests, HTMX partials for `HX-Request`:
153151
154152
```python
155153
@login_required
156
-def item_list(request):
157
- P.ITEM_VIEW.check(request.user)
158
- items = Item.objects.all()
154
+def project_list(request):
155
+ P.PROJECT_VIEW.check(request.user)
156
+ projects = Project.objects.all()
159157
160158
if request.headers.get("HX-Request"):
161
- return render(request, "items/partials/item_table.html", {"items": items})
159
+ return render(request, "projects/partials/project_table.html", {"projects": projects})
162160
163
- return render(request, "items/item_list.html", {"items": items})
161
+ return render(request, "projects/project_list.html", {"projects": projects})
164162
```
165163
166164
**URL patterns** follow CRUD convention:
167165
```python
168166
urlpatterns = [
169
- path("", views.item_list, name="list"),
170
- path("create/", views.item_create, name="create"),
171
- path("<slug:slug>/", views.item_detail, name="detail"),
172
- path("<slug:slug>/edit/", views.item_update, name="update"),
173
- path("<slug:slug>/delete/", views.item_delete, name="delete"),
167
+ path("", views.project_list, name="list"),
168
+ path("create/", views.project_create, name="create"),
169
+ path("<slug:slug>/", views.project_detail, name="detail"),
170
+ path("<slug:slug>/edit/", views.project_update, name="update"),
171
+ path("<slug:slug>/delete/", views.project_delete, name="delete"),
174172
]
175173
```
176174
177175
---
178176
@@ -181,18 +179,18 @@
181179
Group-based. Never user-based. Checked in every view.
182180
183181
```python
184182
from core.permissions import P
185183
186
-P.ITEM_VIEW.check(request.user) # raises PermissionDenied if denied
187
-P.ITEM_ADD.check(request.user, raise_error=False) # returns False instead
184
+P.PROJECT_VIEW.check(request.user) # raises PermissionDenied if denied
185
+P.PROJECT_ADD.check(request.user, raise_error=False) # returns False instead
188186
```
189187
190188
Template guards:
191189
```html
192
-{% if perms.items.view_item %}
193
- <a href="{% url 'items:list' %}">Items</a>
190
+{% if perms.projects.view_project %}
191
+ <a href="{% url 'projects:list' %}">Projects</a>
194192
{% endif %}
195193
```
196194
197195
---
198196
@@ -200,13 +198,13 @@
200198
201199
All admin classes inherit `BaseCoreAdmin`:
202200
```python
203201
from core.admin import BaseCoreAdmin
204202
205
-@admin.register(Item)
206
-class ItemAdmin(BaseCoreAdmin):
207
- list_display = ("name", "slug", "price", "created_at")
203
+@admin.register(Project)
204
+class ProjectAdmin(BaseCoreAdmin):
205
+ list_display = ("name", "slug", "visibility", "created_at")
208206
search_fields = ("name", "slug")
209207
```
210208
211209
`BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export.
212210
@@ -233,21 +231,21 @@
233231
234232
pytest + real Postgres. Assert against database state.
235233
236234
```python
237235
@pytest.mark.django_db
238
-class TestItemCreate:
239
- def test_create_saves_item(self, admin_client, admin_user):
240
- response = admin_client.post(reverse("items:create"), {
241
- "name": "Widget", "price": "9.99", ...
236
+class TestProjectCreate:
237
+ def test_create_saves_project(self, admin_client, admin_user, org):
238
+ response = admin_client.post(reverse("projects:create"), {
239
+ "name": "New App", "visibility": "private", ...
242240
})
243241
assert response.status_code == 302
244
- item = Item.objects.get(name="Widget")
245
- assert item.created_by == admin_user
242
+ project = Project.objects.get(name="New App")
243
+ assert project.created_by == admin_user
246244
247245
def test_create_denied_for_viewer(self, viewer_client):
248
- response = viewer_client.get(reverse("items:create"))
246
+ response = viewer_client.get(reverse("projects:create"))
249247
assert response.status_code == 403
250248
```
251249
252250
Both allowed AND denied permission cases for every endpoint.
253251
254252
--- bootstrap.md
+++ bootstrap.md
@@ -74,13 +74,12 @@
74
75 ```
76 fossilrepo/
77 |-- config/ # Django settings, URLs, Celery
78 |-- core/ # Base models, permissions, middleware
79 |-- auth1/ # Session-based auth
80 |-- organization/ # Org + member management
81 |-- items/ # Example CRUD app (reference only)
82 |-- docker/ # Fossil-specific: Caddyfile, litestream.yml
83 |-- templates/ # HTMX templates
84 |-- _old_fossilrepo/ # Original server/sync/cli code (being ported)
85 +-- docs/ # Architecture guides
86 ```
@@ -89,19 +88,19 @@
89
90 ## What's Already Built
91
92 | Layer | What's there |
93 |---|---|
94 | Auth | Session-based auth (auth1), login/logout views with templates, rate limiting |
95 | Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) |
96 | API | Django views returning HTML (full pages + HTMX partials) |
97 | Permissions | Group-based via `P` enum, checked in every view |
98 | Async | Celery worker + beat, Redis broker |
99 | Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) |
100 | Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit |
101 | CI | GitHub Actions: lint (Ruff) + tests (Postgres + Redis services) |
102 | Seed | `python manage.py seed` creates admin/viewer users, sample items |
103 | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates |
104
105 ---
106
107 ## App Structure
@@ -108,13 +107,12 @@
108
109 | App | Purpose |
110 |---|---|
111 | `config` | Django settings, URLs, Celery configuration |
112 | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware |
113 | `auth1` | Session-based authentication: login/logout views with rate limiting |
114 | `organization` | Organization + OrganizationMember models |
115 | `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) |
116 | `testdata` | `seed` management command for development data |
117
118 ---
119
120 ## Conventions
@@ -134,12 +132,12 @@
134
135 **`BaseCoreModel(Tracking)`** (abstract) -- named entities:
136 ```python
137 from core.models import BaseCoreModel
138
139 class Item(BaseCoreModel):
140 price = models.DecimalField(...)
141 ```
142 Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
143
144 **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
145
@@ -151,28 +149,28 @@
151
152 Views return full pages for normal requests, HTMX partials for `HX-Request`:
153
154 ```python
155 @login_required
156 def item_list(request):
157 P.ITEM_VIEW.check(request.user)
158 items = Item.objects.all()
159
160 if request.headers.get("HX-Request"):
161 return render(request, "items/partials/item_table.html", {"items": items})
162
163 return render(request, "items/item_list.html", {"items": items})
164 ```
165
166 **URL patterns** follow CRUD convention:
167 ```python
168 urlpatterns = [
169 path("", views.item_list, name="list"),
170 path("create/", views.item_create, name="create"),
171 path("<slug:slug>/", views.item_detail, name="detail"),
172 path("<slug:slug>/edit/", views.item_update, name="update"),
173 path("<slug:slug>/delete/", views.item_delete, name="delete"),
174 ]
175 ```
176
177 ---
178
@@ -181,18 +179,18 @@
181 Group-based. Never user-based. Checked in every view.
182
183 ```python
184 from core.permissions import P
185
186 P.ITEM_VIEW.check(request.user) # raises PermissionDenied if denied
187 P.ITEM_ADD.check(request.user, raise_error=False) # returns False instead
188 ```
189
190 Template guards:
191 ```html
192 {% if perms.items.view_item %}
193 <a href="{% url 'items:list' %}">Items</a>
194 {% endif %}
195 ```
196
197 ---
198
@@ -200,13 +198,13 @@
200
201 All admin classes inherit `BaseCoreAdmin`:
202 ```python
203 from core.admin import BaseCoreAdmin
204
205 @admin.register(Item)
206 class ItemAdmin(BaseCoreAdmin):
207 list_display = ("name", "slug", "price", "created_at")
208 search_fields = ("name", "slug")
209 ```
210
211 `BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export.
212
@@ -233,21 +231,21 @@
233
234 pytest + real Postgres. Assert against database state.
235
236 ```python
237 @pytest.mark.django_db
238 class TestItemCreate:
239 def test_create_saves_item(self, admin_client, admin_user):
240 response = admin_client.post(reverse("items:create"), {
241 "name": "Widget", "price": "9.99", ...
242 })
243 assert response.status_code == 302
244 item = Item.objects.get(name="Widget")
245 assert item.created_by == admin_user
246
247 def test_create_denied_for_viewer(self, viewer_client):
248 response = viewer_client.get(reverse("items:create"))
249 assert response.status_code == 403
250 ```
251
252 Both allowed AND denied permission cases for every endpoint.
253
254
--- bootstrap.md
+++ bootstrap.md
@@ -74,13 +74,12 @@
74
75 ```
76 fossilrepo/
77 |-- config/ # Django settings, URLs, Celery
78 |-- core/ # Base models, permissions, middleware
79 |-- accounts/ # Session-based auth
80 |-- organization/ # Org + member management
 
81 |-- docker/ # Fossil-specific: Caddyfile, litestream.yml
82 |-- templates/ # HTMX templates
83 |-- _old_fossilrepo/ # Original server/sync/cli code (being ported)
84 +-- docs/ # Architecture guides
85 ```
@@ -89,19 +88,19 @@
88
89 ## What's Already Built
90
91 | Layer | What's there |
92 |---|---|
93 | Auth | Session-based auth (accounts), login/logout views with templates, rate limiting |
94 | Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) |
95 | API | Django views returning HTML (full pages + HTMX partials) |
96 | Permissions | Group-based via `P` enum, checked in every view |
97 | Async | Celery worker + beat, Redis broker |
98 | Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) |
99 | Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit |
100 | CI | GitHub Actions: lint (Ruff) + tests (Postgres + Redis services) |
101 | Seed | `python manage.py seed` creates admin/viewer users, sample data |
102 | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates |
103
104 ---
105
106 ## App Structure
@@ -108,13 +107,12 @@
107
108 | App | Purpose |
109 |---|---|
110 | `config` | Django settings, URLs, Celery configuration |
111 | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware |
112 | `accounts` | Session-based authentication: login/logout views with rate limiting |
113 | `organization` | Organization + OrganizationMember models |
 
114 | `testdata` | `seed` management command for development data |
115
116 ---
117
118 ## Conventions
@@ -134,12 +132,12 @@
132
133 **`BaseCoreModel(Tracking)`** (abstract) -- named entities:
134 ```python
135 from core.models import BaseCoreModel
136
137 class Project(BaseCoreModel):
138 visibility = models.CharField(...)
139 ```
140 Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
141
142 **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
143
@@ -151,28 +149,28 @@
149
150 Views return full pages for normal requests, HTMX partials for `HX-Request`:
151
152 ```python
153 @login_required
154 def project_list(request):
155 P.PROJECT_VIEW.check(request.user)
156 projects = Project.objects.all()
157
158 if request.headers.get("HX-Request"):
159 return render(request, "projects/partials/project_table.html", {"projects": projects})
160
161 return render(request, "projects/project_list.html", {"projects": projects})
162 ```
163
164 **URL patterns** follow CRUD convention:
165 ```python
166 urlpatterns = [
167 path("", views.project_list, name="list"),
168 path("create/", views.project_create, name="create"),
169 path("<slug:slug>/", views.project_detail, name="detail"),
170 path("<slug:slug>/edit/", views.project_update, name="update"),
171 path("<slug:slug>/delete/", views.project_delete, name="delete"),
172 ]
173 ```
174
175 ---
176
@@ -181,18 +179,18 @@
179 Group-based. Never user-based. Checked in every view.
180
181 ```python
182 from core.permissions import P
183
184 P.PROJECT_VIEW.check(request.user) # raises PermissionDenied if denied
185 P.PROJECT_ADD.check(request.user, raise_error=False) # returns False instead
186 ```
187
188 Template guards:
189 ```html
190 {% if perms.projects.view_project %}
191 <a href="{% url 'projects:list' %}">Projects</a>
192 {% endif %}
193 ```
194
195 ---
196
@@ -200,13 +198,13 @@
198
199 All admin classes inherit `BaseCoreAdmin`:
200 ```python
201 from core.admin import BaseCoreAdmin
202
203 @admin.register(Project)
204 class ProjectAdmin(BaseCoreAdmin):
205 list_display = ("name", "slug", "visibility", "created_at")
206 search_fields = ("name", "slug")
207 ```
208
209 `BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export.
210
@@ -233,21 +231,21 @@
231
232 pytest + real Postgres. Assert against database state.
233
234 ```python
235 @pytest.mark.django_db
236 class TestProjectCreate:
237 def test_create_saves_project(self, admin_client, admin_user, org):
238 response = admin_client.post(reverse("projects:create"), {
239 "name": "New App", "visibility": "private", ...
240 })
241 assert response.status_code == 302
242 project = Project.objects.get(name="New App")
243 assert project.created_by == admin_user
244
245 def test_create_denied_for_viewer(self, viewer_client):
246 response = viewer_client.get(reverse("projects:create"))
247 assert response.status_code == 403
248 ```
249
250 Both allowed AND denied permission cases for every endpoint.
251
252
--- config/settings.py
+++ config/settings.py
@@ -57,13 +57,12 @@
5757
"corsheaders",
5858
"constance",
5959
"constance.backends.database",
6060
# Project apps
6161
"core",
62
- "auth1",
62
+ "accounts",
6363
"organization",
64
- "items",
6564
"projects",
6665
"pages",
6766
"fossil",
6867
"testdata",
6968
]
@@ -201,10 +200,20 @@
201200
},
202201
"fossil-dispatch-notifications": {
203202
"task": "fossil.dispatch_notifications",
204203
"schedule": 300.0, # every 5 minutes
205204
},
205
+ "fossil-daily-digest": {
206
+ "task": "fossil.send_digest",
207
+ "schedule": 86400.0, # daily
208
+ "kwargs": {"mode": "daily"},
209
+ },
210
+ "fossil-weekly-digest": {
211
+ "task": "fossil.send_digest",
212
+ "schedule": 604800.0, # weekly
213
+ "kwargs": {"mode": "weekly"},
214
+ },
206215
}
207216
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
208217
209218
# --- CORS ---
210219
211220
--- config/settings.py
+++ config/settings.py
@@ -57,13 +57,12 @@
57 "corsheaders",
58 "constance",
59 "constance.backends.database",
60 # Project apps
61 "core",
62 "auth1",
63 "organization",
64 "items",
65 "projects",
66 "pages",
67 "fossil",
68 "testdata",
69 ]
@@ -201,10 +200,20 @@
201 },
202 "fossil-dispatch-notifications": {
203 "task": "fossil.dispatch_notifications",
204 "schedule": 300.0, # every 5 minutes
205 },
 
 
 
 
 
 
 
 
 
 
206 }
207 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
208
209 # --- CORS ---
210
211
--- config/settings.py
+++ config/settings.py
@@ -57,13 +57,12 @@
57 "corsheaders",
58 "constance",
59 "constance.backends.database",
60 # Project apps
61 "core",
62 "accounts",
63 "organization",
 
64 "projects",
65 "pages",
66 "fossil",
67 "testdata",
68 ]
@@ -201,10 +200,20 @@
200 },
201 "fossil-dispatch-notifications": {
202 "task": "fossil.dispatch_notifications",
203 "schedule": 300.0, # every 5 minutes
204 },
205 "fossil-daily-digest": {
206 "task": "fossil.send_digest",
207 "schedule": 86400.0, # daily
208 "kwargs": {"mode": "daily"},
209 },
210 "fossil-weekly-digest": {
211 "task": "fossil.send_digest",
212 "schedule": 604800.0, # weekly
213 "kwargs": {"mode": "weekly"},
214 },
215 }
216 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
217
218 # --- CORS ---
219
220
+36 -7
--- config/urls.py
+++ config/urls.py
@@ -9,14 +9,25 @@
99
from django.views.generic import RedirectView
1010
1111
1212
def _oauth_github_callback(request):
1313
"""Global GitHub OAuth callback. Extracts slug from state param and delegates."""
14
+ from django.contrib import messages
15
+
1416
state = request.GET.get("state", "")
15
- slug = state.split(":")[0] if ":" in state else ""
16
- if not slug:
17
+ parts = state.split(":")
18
+ if len(parts) < 3:
1719
return _redirect("/dashboard/")
20
+
21
+ slug = parts[0]
22
+ nonce = parts[2]
23
+
24
+ expected_nonce = request.session.pop("oauth_state_nonce", "")
25
+ if not nonce or nonce != expected_nonce:
26
+ messages.error(request, "OAuth state mismatch. Please try again.")
27
+ return _redirect(f"/projects/{slug}/fossil/sync/git/")
28
+
1829
from fossil.oauth import github_exchange_token
1930
2031
result = github_exchange_token(request, slug)
2132
if result.get("token"):
2233
request.session["github_oauth_token"] = result["token"]
@@ -24,14 +35,25 @@
2435
return _redirect(f"/projects/{slug}/fossil/sync/git/")
2536
2637
2738
def _oauth_gitlab_callback(request):
2839
"""Global GitLab OAuth callback. Extracts slug from state param and delegates."""
40
+ from django.contrib import messages
41
+
2942
state = request.GET.get("state", "")
30
- slug = state.split(":")[0] if ":" in state else ""
31
- if not slug:
43
+ parts = state.split(":")
44
+ if len(parts) < 3:
3245
return _redirect("/dashboard/")
46
+
47
+ slug = parts[0]
48
+ nonce = parts[2]
49
+
50
+ expected_nonce = request.session.pop("oauth_state_nonce", "")
51
+ if not nonce or nonce != expected_nonce:
52
+ messages.error(request, "OAuth state mismatch. Please try again.")
53
+ return _redirect(f"/projects/{slug}/fossil/sync/git/")
54
+
3355
from fossil.oauth import gitlab_exchange_token
3456
3557
result = gitlab_exchange_token(request, slug)
3658
if result.get("token"):
3759
request.session["gitlab_oauth_token"] = result["token"]
@@ -213,21 +235,28 @@
213235
</body>
214236
</html>"""
215237
216238
return HttpResponse(html)
217239
240
+
241
+def _explore_view(request):
242
+ from projects.views import explore
243
+
244
+ return explore(request)
245
+
218246
219247
urlpatterns = [
220
- path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
248
+ path("", lambda request: _redirect("/explore/") if not request.user.is_authenticated else _redirect("/dashboard/"), name="home"),
249
+ path("profile/", RedirectView.as_view(pattern_name="accounts:profile", permanent=False)),
221250
path("status/", status_page, name="status"),
251
+ path("explore/", _explore_view, name="explore"),
222252
path("dashboard/", include("core.urls")),
223
- path("auth/", include("auth1.urls")),
253
+ path("auth/", include("accounts.urls")),
224254
path("settings/", include("organization.urls")),
225255
path("projects/", include("projects.urls")),
226256
path("projects/<slug:slug>/fossil/", include("fossil.urls")),
227257
path("kb/", include("pages.urls")),
228
- path("items/", include("items.urls")),
229258
path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"),
230259
path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"),
231260
path("admin/", admin.site.urls),
232261
path("health/", health_check, name="health"),
233262
]
234263
--- config/urls.py
+++ config/urls.py
@@ -9,14 +9,25 @@
9 from django.views.generic import RedirectView
10
11
12 def _oauth_github_callback(request):
13 """Global GitHub OAuth callback. Extracts slug from state param and delegates."""
 
 
14 state = request.GET.get("state", "")
15 slug = state.split(":")[0] if ":" in state else ""
16 if not slug:
17 return _redirect("/dashboard/")
 
 
 
 
 
 
 
 
 
18 from fossil.oauth import github_exchange_token
19
20 result = github_exchange_token(request, slug)
21 if result.get("token"):
22 request.session["github_oauth_token"] = result["token"]
@@ -24,14 +35,25 @@
24 return _redirect(f"/projects/{slug}/fossil/sync/git/")
25
26
27 def _oauth_gitlab_callback(request):
28 """Global GitLab OAuth callback. Extracts slug from state param and delegates."""
 
 
29 state = request.GET.get("state", "")
30 slug = state.split(":")[0] if ":" in state else ""
31 if not slug:
32 return _redirect("/dashboard/")
 
 
 
 
 
 
 
 
 
33 from fossil.oauth import gitlab_exchange_token
34
35 result = gitlab_exchange_token(request, slug)
36 if result.get("token"):
37 request.session["gitlab_oauth_token"] = result["token"]
@@ -213,21 +235,28 @@
213 </body>
214 </html>"""
215
216 return HttpResponse(html)
217
 
 
 
 
 
 
218
219 urlpatterns = [
220 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
 
221 path("status/", status_page, name="status"),
 
222 path("dashboard/", include("core.urls")),
223 path("auth/", include("auth1.urls")),
224 path("settings/", include("organization.urls")),
225 path("projects/", include("projects.urls")),
226 path("projects/<slug:slug>/fossil/", include("fossil.urls")),
227 path("kb/", include("pages.urls")),
228 path("items/", include("items.urls")),
229 path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"),
230 path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"),
231 path("admin/", admin.site.urls),
232 path("health/", health_check, name="health"),
233 ]
234
--- config/urls.py
+++ config/urls.py
@@ -9,14 +9,25 @@
9 from django.views.generic import RedirectView
10
11
12 def _oauth_github_callback(request):
13 """Global GitHub OAuth callback. Extracts slug from state param and delegates."""
14 from django.contrib import messages
15
16 state = request.GET.get("state", "")
17 parts = state.split(":")
18 if len(parts) < 3:
19 return _redirect("/dashboard/")
20
21 slug = parts[0]
22 nonce = parts[2]
23
24 expected_nonce = request.session.pop("oauth_state_nonce", "")
25 if not nonce or nonce != expected_nonce:
26 messages.error(request, "OAuth state mismatch. Please try again.")
27 return _redirect(f"/projects/{slug}/fossil/sync/git/")
28
29 from fossil.oauth import github_exchange_token
30
31 result = github_exchange_token(request, slug)
32 if result.get("token"):
33 request.session["github_oauth_token"] = result["token"]
@@ -24,14 +35,25 @@
35 return _redirect(f"/projects/{slug}/fossil/sync/git/")
36
37
38 def _oauth_gitlab_callback(request):
39 """Global GitLab OAuth callback. Extracts slug from state param and delegates."""
40 from django.contrib import messages
41
42 state = request.GET.get("state", "")
43 parts = state.split(":")
44 if len(parts) < 3:
45 return _redirect("/dashboard/")
46
47 slug = parts[0]
48 nonce = parts[2]
49
50 expected_nonce = request.session.pop("oauth_state_nonce", "")
51 if not nonce or nonce != expected_nonce:
52 messages.error(request, "OAuth state mismatch. Please try again.")
53 return _redirect(f"/projects/{slug}/fossil/sync/git/")
54
55 from fossil.oauth import gitlab_exchange_token
56
57 result = gitlab_exchange_token(request, slug)
58 if result.get("token"):
59 request.session["gitlab_oauth_token"] = result["token"]
@@ -213,21 +235,28 @@
235 </body>
236 </html>"""
237
238 return HttpResponse(html)
239
240
241 def _explore_view(request):
242 from projects.views import explore
243
244 return explore(request)
245
246
247 urlpatterns = [
248 path("", lambda request: _redirect("/explore/") if not request.user.is_authenticated else _redirect("/dashboard/"), name="home"),
249 path("profile/", RedirectView.as_view(pattern_name="accounts:profile", permanent=False)),
250 path("status/", status_page, name="status"),
251 path("explore/", _explore_view, name="explore"),
252 path("dashboard/", include("core.urls")),
253 path("auth/", include("accounts.urls")),
254 path("settings/", include("organization.urls")),
255 path("projects/", include("projects.urls")),
256 path("projects/<slug:slug>/fossil/", include("fossil.urls")),
257 path("kb/", include("pages.urls")),
 
258 path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"),
259 path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"),
260 path("admin/", admin.site.urls),
261 path("health/", health_check, name="health"),
262 ]
263
+1 -1
--- conftest.py
+++ conftest.py
@@ -15,11 +15,11 @@
1515
@pytest.fixture
1616
def viewer_user(db):
1717
user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
1818
group, _ = Group.objects.get_or_create(name="Viewers")
1919
view_perms = Permission.objects.filter(
20
- content_type__app_label__in=["items", "organization", "projects", "pages"],
20
+ content_type__app_label__in=["organization", "projects", "pages"],
2121
codename__startswith="view_",
2222
)
2323
group.permissions.set(view_perms)
2424
user.groups.add(group)
2525
return user
2626
--- conftest.py
+++ conftest.py
@@ -15,11 +15,11 @@
15 @pytest.fixture
16 def viewer_user(db):
17 user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
18 group, _ = Group.objects.get_or_create(name="Viewers")
19 view_perms = Permission.objects.filter(
20 content_type__app_label__in=["items", "organization", "projects", "pages"],
21 codename__startswith="view_",
22 )
23 group.permissions.set(view_perms)
24 user.groups.add(group)
25 return user
26
--- conftest.py
+++ conftest.py
@@ -15,11 +15,11 @@
15 @pytest.fixture
16 def viewer_user(db):
17 user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
18 group, _ = Group.objects.get_or_create(name="Viewers")
19 view_perms = Permission.objects.filter(
20 content_type__app_label__in=["organization", "projects", "pages"],
21 codename__startswith="view_",
22 )
23 group.permissions.set(view_perms)
24 user.groups.add(group)
25 return user
26
--- core/admin.py
+++ core/admin.py
@@ -3,10 +3,15 @@
33
44
55
class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin):
66
"""Base admin class for all Fossilrepo models. Provides audit field handling and import/export."""
77
8
+ def get_queryset(self, request):
9
+ if hasattr(self.model, "all_objects"):
10
+ return self.model.all_objects.all()
11
+ return super().get_queryset(request)
12
+
813
def get_readonly_fields(self, request, obj=None):
914
base = tuple(self.readonly_fields or ())
1015
return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by")
1116
1217
def get_raw_id_fields(self, request):
1318
--- core/admin.py
+++ core/admin.py
@@ -3,10 +3,15 @@
3
4
5 class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin):
6 """Base admin class for all Fossilrepo models. Provides audit field handling and import/export."""
7
 
 
 
 
 
8 def get_readonly_fields(self, request, obj=None):
9 base = tuple(self.readonly_fields or ())
10 return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by")
11
12 def get_raw_id_fields(self, request):
13
--- core/admin.py
+++ core/admin.py
@@ -3,10 +3,15 @@
3
4
5 class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin):
6 """Base admin class for all Fossilrepo models. Provides audit field handling and import/export."""
7
8 def get_queryset(self, request):
9 if hasattr(self.model, "all_objects"):
10 return self.model.all_objects.all()
11 return super().get_queryset(request)
12
13 def get_readonly_fields(self, request, obj=None):
14 base = tuple(self.readonly_fields or ())
15 return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by")
16
17 def get_raw_id_fields(self, request):
18
--- core/context_processors.py
+++ core/context_processors.py
@@ -1,17 +1,32 @@
11
from pages.models import Page
2
-from projects.models import Project
2
+from projects.models import Project, ProjectGroup
33
44
55
def sidebar(request):
66
if not request.user.is_authenticated:
77
return {}
88
9
- projects = Project.objects.all()
9
+ projects = Project.objects.all().select_related("group")
1010
pages = Page.objects.filter(is_published=True)
1111
if request.user.has_perm("pages.change_page") or request.user.is_superuser:
1212
pages = Page.objects.all()
1313
14
+ # Build grouped structure for sidebar
15
+ groups = ProjectGroup.objects.filter(deleted_at__isnull=True)
16
+
17
+ grouped_projects = []
18
+ grouped_ids = set()
19
+ for group in groups:
20
+ group_projects = [p for p in projects if p.group_id == group.id]
21
+ if group_projects:
22
+ grouped_projects.append({"group": group, "projects": group_projects})
23
+ grouped_ids.update(p.id for p in group_projects)
24
+
25
+ ungrouped_projects = [p for p in projects if p.id not in grouped_ids]
26
+
1427
return {
1528
"sidebar_projects": projects,
29
+ "sidebar_grouped": grouped_projects,
30
+ "sidebar_ungrouped": ungrouped_projects,
1631
"sidebar_pages": pages,
1732
}
1833
1934
ADDED core/decorators.py
2035
ADDED core/fields.py
2136
ADDED core/pagination.py
--- core/context_processors.py
+++ core/context_processors.py
@@ -1,17 +1,32 @@
1 from pages.models import Page
2 from projects.models import Project
3
4
5 def sidebar(request):
6 if not request.user.is_authenticated:
7 return {}
8
9 projects = Project.objects.all()
10 pages = Page.objects.filter(is_published=True)
11 if request.user.has_perm("pages.change_page") or request.user.is_superuser:
12 pages = Page.objects.all()
13
 
 
 
 
 
 
 
 
 
 
 
 
 
14 return {
15 "sidebar_projects": projects,
 
 
16 "sidebar_pages": pages,
17 }
18
19 DDED core/decorators.py
20 DDED core/fields.py
21 DDED core/pagination.py
--- core/context_processors.py
+++ core/context_processors.py
@@ -1,17 +1,32 @@
1 from pages.models import Page
2 from projects.models import Project, ProjectGroup
3
4
5 def sidebar(request):
6 if not request.user.is_authenticated:
7 return {}
8
9 projects = Project.objects.all().select_related("group")
10 pages = Page.objects.filter(is_published=True)
11 if request.user.has_perm("pages.change_page") or request.user.is_superuser:
12 pages = Page.objects.all()
13
14 # Build grouped structure for sidebar
15 groups = ProjectGroup.objects.filter(deleted_at__isnull=True)
16
17 grouped_projects = []
18 grouped_ids = set()
19 for group in groups:
20 group_projects = [p for p in projects if p.group_id == group.id]
21 if group_projects:
22 grouped_projects.append({"group": group, "projects": group_projects})
23 grouped_ids.update(p.id for p in group_projects)
24
25 ungrouped_projects = [p for p in projects if p.id not in grouped_ids]
26
27 return {
28 "sidebar_projects": projects,
29 "sidebar_grouped": grouped_projects,
30 "sidebar_ungrouped": ungrouped_projects,
31 "sidebar_pages": pages,
32 }
33
34 DDED core/decorators.py
35 DDED core/fields.py
36 DDED core/pagination.py
--- a/core/decorators.py
+++ b/core/decorators.py
@@ -0,0 +1,39 @@
1
+"""Custom view decorators for fossilrepo."""
2
+
3
+from functools import wraps
4
+
5
+from django.contrib.auth.decorators import login_required
6
+
7
+
8
+def public_or_login(view_func):
9
+ """Allow anonymous access to public project views.
10
+
11
+ For views that take a `slug` parameter: if the project is public,
12
+ let anonymous users through. Otherwise, redirect to login.
13
+
14
+ This replaces @login_required on read-only project/fossil views.
15
+ The view itself must still call require_project_read() for the
16
+ actual permission check.
17
+ """
18
+
19
+ @wraps(view_func)
20
+ def wrapper(request, *args, **kwargs):
21
+ if request.user.is_authenticated:
22
+ return view_func(request, *args, **kwargs)
23
+
24
+ # Check if this is a public project
25
+ slug = kwargs.get("slug") or (args[0] if args else None)
26
+ if slug:
27
+ from projects.models import Project
28
+
29
+ try:
30
+ project = Project.objects.get(slug=slug, deleted_at__isnull=True)
31
+ if project.visibility == "public":
32
+ return view_func(request, *args, **kwargs)
33
+ except Project.DoesNotExist:
34
+ pass
35
+
36
+ # Not public or no slug -- require login
37
+ return login_required(view_func)(request, *args, **kwargs)
38
+
39
+ return wrapper
--- a/core/decorators.py
+++ b/core/decorators.py
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/decorators.py
+++ b/core/decorators.py
@@ -0,0 +1,39 @@
1 """Custom view decorators for fossilrepo."""
2
3 from functools import wraps
4
5 from django.contrib.auth.decorators import login_required
6
7
8 def public_or_login(view_func):
9 """Allow anonymous access to public project views.
10
11 For views that take a `slug` parameter: if the project is public,
12 let anonymous users through. Otherwise, redirect to login.
13
14 This replaces @login_required on read-only project/fossil views.
15 The view itself must still call require_project_read() for the
16 actual permission check.
17 """
18
19 @wraps(view_func)
20 def wrapper(request, *args, **kwargs):
21 if request.user.is_authenticated:
22 return view_func(request, *args, **kwargs)
23
24 # Check if this is a public project
25 slug = kwargs.get("slug") or (args[0] if args else None)
26 if slug:
27 from projects.models import Project
28
29 try:
30 project = Project.objects.get(slug=slug, deleted_at__isnull=True)
31 if project.visibility == "public":
32 return view_func(request, *args, **kwargs)
33 except Project.DoesNotExist:
34 pass
35
36 # Not public or no slug -- require login
37 return login_required(view_func)(request, *args, **kwargs)
38
39 return wrapper
--- a/core/fields.py
+++ b/core/fields.py
@@ -0,0 +1,42 @@
1
+"""Custom model fields — encrypted storage using Fernet symmetric encryption."""
2
+
3
+import base64
4
+import hashlib
5
+
6
+from cryptography.fernet import Fernet, InvalidToken
7
+from django.conf import settings
8
+from django.db import models
9
+
10
+
11
+def _get_fernet():
12
+ """Derive a Fernet key from Django's SECRET_KEY."""
13
+ key_bytes = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
14
+ return Fernet(base64.urlsafe_b64encode(key_bytes))
15
+
16
+
17
+class EncryptedTextField(models.TextField):
18
+ """TextField that encrypts data at rest using Fernet (AES-128-CBC + HMAC).
19
+
20
+ Values are transparently encrypted on save and decrypted on read.
21
+ Stored as base64-encoded ciphertext in the database.
22
+ """
23
+
24
+ def get_prep_value(self, value):
25
+ if value is None or value == "":
26
+ return value
27
+ f = _get_fernet()
28
+ return f.encrypt(value.encode("utf-8")).decode("utf-8")
29
+
30
+ def from_db_value(self, value, expression, connection):
31
+ if value is None or value == "":
32
+ return value
33
+ f = _get_fernet()
34
+ try:
35
+ return f.decrypt(value.encode("utf-8")).decode("utf-8")
36
+ except InvalidToken:
37
+ # Value may not be encrypted (e.g. pre-existing data).
38
+ return value
39
+
40
+ def deconstruct(self):
41
+ name, path, args, kwargs = super().deconstruct()
42
+ return name, path, args, kwargs
--- a/core/fields.py
+++ b/core/fields.py
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/fields.py
+++ b/core/fields.py
@@ -0,0 +1,42 @@
1 """Custom model fields — encrypted storage using Fernet symmetric encryption."""
2
3 import base64
4 import hashlib
5
6 from cryptography.fernet import Fernet, InvalidToken
7 from django.conf import settings
8 from django.db import models
9
10
11 def _get_fernet():
12 """Derive a Fernet key from Django's SECRET_KEY."""
13 key_bytes = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
14 return Fernet(base64.urlsafe_b64encode(key_bytes))
15
16
17 class EncryptedTextField(models.TextField):
18 """TextField that encrypts data at rest using Fernet (AES-128-CBC + HMAC).
19
20 Values are transparently encrypted on save and decrypted on read.
21 Stored as base64-encoded ciphertext in the database.
22 """
23
24 def get_prep_value(self, value):
25 if value is None or value == "":
26 return value
27 f = _get_fernet()
28 return f.encrypt(value.encode("utf-8")).decode("utf-8")
29
30 def from_db_value(self, value, expression, connection):
31 if value is None or value == "":
32 return value
33 f = _get_fernet()
34 try:
35 return f.decrypt(value.encode("utf-8")).decode("utf-8")
36 except InvalidToken:
37 # Value may not be encrypted (e.g. pre-existing data).
38 return value
39
40 def deconstruct(self):
41 name, path, args, kwargs = super().deconstruct()
42 return name, path, args, kwargs
--- a/core/pagination.py
+++ b/core/pagination.py
@@ -0,0 +1,43 @@
1
+"""Shared pagination helpers used across all list views."""
2
+
3
+import math
4
+
5
+PER_PAGE_OPTIONS = [25, 50, 100]
6
+
7
+
8
+def get_per_page(request, default=25):
9
+ """Get per_page from request, constrained to PER_PAGE_OPTIONS."""
10
+ try:
11
+ per_page = int(request.GET.get("per_page", default))
12
+ except (ValueError, TypeError):
13
+ per_page = default
14
+ return per_page if per_page in PER_PAGE_OPTIONS else default
15
+
16
+
17
+def manual_paginate(items, request, per_page=None):
18
+ """Paginate a plain list and return (sliced_items, pagination_dict).
19
+
20
+ The pagination dict has keys compatible with the _pagination_manual.html partial:
21
+ has_previous, has_next, previous_page_number, next_page_number, number, num_pages, count.
22
+ """
23
+ if per_page is None:
24
+ per_page = get_per_page(request)
25
+ total = len(items)
26
+ num_pages = max(1, math.ceil(total / per_page))
27
+ try:
28
+ page = int(request.GET.get("page", 1))
29
+ except (ValueError, TypeError):
30
+ page = 1
31
+ page = max(1, min(page, num_pages))
32
+ offset = (page - 1) * per_page
33
+ sliced = items[offset : offset + per_page]
34
+ pagination = {
35
+ "has_previous": page > 1,
36
+ "has_next": offset + per_page < total,
37
+ "previous_page_number": page - 1,
38
+ "next_page_number": page + 1,
39
+ "number": page,
40
+ "num_pages": num_pages,
41
+ "count": total,
42
+ }
43
+ return sliced, pagination
--- a/core/pagination.py
+++ b/core/pagination.py
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/pagination.py
+++ b/core/pagination.py
@@ -0,0 +1,43 @@
1 """Shared pagination helpers used across all list views."""
2
3 import math
4
5 PER_PAGE_OPTIONS = [25, 50, 100]
6
7
8 def get_per_page(request, default=25):
9 """Get per_page from request, constrained to PER_PAGE_OPTIONS."""
10 try:
11 per_page = int(request.GET.get("per_page", default))
12 except (ValueError, TypeError):
13 per_page = default
14 return per_page if per_page in PER_PAGE_OPTIONS else default
15
16
17 def manual_paginate(items, request, per_page=None):
18 """Paginate a plain list and return (sliced_items, pagination_dict).
19
20 The pagination dict has keys compatible with the _pagination_manual.html partial:
21 has_previous, has_next, previous_page_number, next_page_number, number, num_pages, count.
22 """
23 if per_page is None:
24 per_page = get_per_page(request)
25 total = len(items)
26 num_pages = max(1, math.ceil(total / per_page))
27 try:
28 page = int(request.GET.get("page", 1))
29 except (ValueError, TypeError):
30 page = 1
31 page = max(1, min(page, num_pages))
32 offset = (page - 1) * per_page
33 sliced = items[offset : offset + per_page]
34 pagination = {
35 "has_previous": page > 1,
36 "has_next": offset + per_page < total,
37 "previous_page_number": page - 1,
38 "next_page_number": page + 1,
39 "number": page,
40 "num_pages": num_pages,
41 "count": total,
42 }
43 return sliced, pagination
--- core/permissions.py
+++ core/permissions.py
@@ -25,10 +25,16 @@
2525
TEAM_VIEW = "organization.view_team"
2626
TEAM_ADD = "organization.add_team"
2727
TEAM_CHANGE = "organization.change_team"
2828
TEAM_DELETE = "organization.delete_team"
2929
30
+ # Project Groups
31
+ PROJECT_GROUP_VIEW = "projects.view_projectgroup"
32
+ PROJECT_GROUP_ADD = "projects.add_projectgroup"
33
+ PROJECT_GROUP_CHANGE = "projects.change_projectgroup"
34
+ PROJECT_GROUP_DELETE = "projects.delete_projectgroup"
35
+
3036
# Projects
3137
PROJECT_VIEW = "projects.view_project"
3238
PROJECT_ADD = "projects.add_project"
3339
PROJECT_CHANGE = "projects.change_project"
3440
PROJECT_DELETE = "projects.delete_project"
@@ -43,16 +49,10 @@
4349
PAGE_VIEW = "pages.view_page"
4450
PAGE_ADD = "pages.add_page"
4551
PAGE_CHANGE = "pages.change_page"
4652
PAGE_DELETE = "pages.delete_page"
4753
48
- # Items (example domain)
49
- ITEM_VIEW = "items.view_item"
50
- ITEM_ADD = "items.add_item"
51
- ITEM_CHANGE = "items.change_item"
52
- ITEM_DELETE = "items.delete_item"
53
-
5454
def check(self, user, raise_error=True):
5555
"""Check if user has this permission. Superusers always pass."""
5656
if not user or not user.is_authenticated:
5757
if raise_error:
5858
raise PermissionDenied("Authentication required.")
5959
6060
ADDED core/sanitize.py
--- core/permissions.py
+++ core/permissions.py
@@ -25,10 +25,16 @@
25 TEAM_VIEW = "organization.view_team"
26 TEAM_ADD = "organization.add_team"
27 TEAM_CHANGE = "organization.change_team"
28 TEAM_DELETE = "organization.delete_team"
29
 
 
 
 
 
 
30 # Projects
31 PROJECT_VIEW = "projects.view_project"
32 PROJECT_ADD = "projects.add_project"
33 PROJECT_CHANGE = "projects.change_project"
34 PROJECT_DELETE = "projects.delete_project"
@@ -43,16 +49,10 @@
43 PAGE_VIEW = "pages.view_page"
44 PAGE_ADD = "pages.add_page"
45 PAGE_CHANGE = "pages.change_page"
46 PAGE_DELETE = "pages.delete_page"
47
48 # Items (example domain)
49 ITEM_VIEW = "items.view_item"
50 ITEM_ADD = "items.add_item"
51 ITEM_CHANGE = "items.change_item"
52 ITEM_DELETE = "items.delete_item"
53
54 def check(self, user, raise_error=True):
55 """Check if user has this permission. Superusers always pass."""
56 if not user or not user.is_authenticated:
57 if raise_error:
58 raise PermissionDenied("Authentication required.")
59
60 DDED core/sanitize.py
--- core/permissions.py
+++ core/permissions.py
@@ -25,10 +25,16 @@
25 TEAM_VIEW = "organization.view_team"
26 TEAM_ADD = "organization.add_team"
27 TEAM_CHANGE = "organization.change_team"
28 TEAM_DELETE = "organization.delete_team"
29
30 # Project Groups
31 PROJECT_GROUP_VIEW = "projects.view_projectgroup"
32 PROJECT_GROUP_ADD = "projects.add_projectgroup"
33 PROJECT_GROUP_CHANGE = "projects.change_projectgroup"
34 PROJECT_GROUP_DELETE = "projects.delete_projectgroup"
35
36 # Projects
37 PROJECT_VIEW = "projects.view_project"
38 PROJECT_ADD = "projects.add_project"
39 PROJECT_CHANGE = "projects.change_project"
40 PROJECT_DELETE = "projects.delete_project"
@@ -43,16 +49,10 @@
49 PAGE_VIEW = "pages.view_page"
50 PAGE_ADD = "pages.add_page"
51 PAGE_CHANGE = "pages.change_page"
52 PAGE_DELETE = "pages.delete_page"
53
 
 
 
 
 
 
54 def check(self, user, raise_error=True):
55 """Check if user has this permission. Superusers always pass."""
56 if not user or not user.is_authenticated:
57 if raise_error:
58 raise PermissionDenied("Authentication required.")
59
60 DDED core/sanitize.py
--- a/core/sanitize.py
+++ b/core/sanitize.py
@@ -0,0 +1,135 @@
1
+"""HTML sanitization for user-generated content.
2
+
3
+Strips dangerous tags (<script>, <style>, <iframe>, etc.), event handlers (on*),
4
+and dangerous URL protocols (javascript:, data:, vbscript:) while preserving
5
+safe formatting tags used by Fossil wiki, Markdown, and Pikchr diagrams.
6
+"""
7
+
8
+import re
9
+
10
+# Tags that are safe to render -- covers Markdown/wiki formatting and Pikchr SVG
11
+ALLOWED_TAGS = {
12
+ "a",
13
+ "abbr",
14
+ "acronym",
15
+ "b",
16
+ "blockquote",
17
+ "br",
18
+ "code",
19
+ "dd",
20
+ "del",
21
+ "details",
22
+ "div",
23
+ "dl",
24
+ "dt",
25
+ "em",
26
+ "h1",
27
+ "h2",
28
+ "h3",
29
+ "h4",
30
+ "h5",
31
+ "h6",
32
+ "hr",
33
+ "i",
34
+ "img",
35
+ "ins",
36
+ "kbd",
37
+ "li",
38
+ "mark",
39
+ "ol",
40
+ "p",
41
+ "pre",
42
+ "q",
43
+ "s",
44
+ "samp",
45
+ "small",
46
+ "span",
47
+ "strong",
48
+ "sub",
49
+ "summary",
50
+ "sup",
51
+ "table",
52
+ "tbody",
53
+ "td",
54
+ "tfoot",
55
+ "th",
56
+ "thead",
57
+ "tr",
58
+ "tt",
59
+ "u",
60
+ "ul",
61
+ "var",
62
+ # SVG elements for Pikchr diagrams
63
+ "svg",
64
+ "path",
65
+ "circle",
66
+ "rect",
67
+ "line",
68
+ "polyline",
69
+ "polygon",
70
+ "g",
71
+ "text",
72
+ "defs",
73
+ "use",
74
+ "symbol",
75
+}
76
+
77
+# Tags whose entire content (not just the tag) must be removed
78
+_DANGEROUS_CONTENT_TAGS = re.compile(
79
+ r"<\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>",
80
+ re.IGNORECASE | re.DOTALL,
81
+)
82
+
83
+# Self-closing / unclosed dangerous tags
84
+_DANGEROUS_SELF_CLOSING = re.compile(
85
+ r"<\s*/?\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*/?\s*>",
86
+ re.IGNORECASE,
87
+)
88
+
89
+# Event handler attributes (onclick, onload, onerror, etc.)
90
+_EVENT_HANDLERS = re.compile(
91
+ r"""\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)""",
92
+ re.IGNORECASE,
93
+)
94
+
95
+# Dangerous protocols in href/src values
96
+_DANGEROUS_PROTOCOL = re.compile(r"^\s*(?:javascript|vbscript|data):", re.IGNORECASE)
97
+
98
+# href="..." and src="..." attribute pattern
99
+_URL_ATTR = re.compile(r"""(href|src)\s*=\s*(["']?)([^"'>\s]+)\2""", re.IGNORECASE)
100
+
101
+
102
+def _clean_url_attr(match: re.Match) -> str:
103
+ """Replace dangerous protocol URLs with a safe '#' anchor."""
104
+ attr_name = match.group(1)
105
+ quote = match.group(2) or ""
106
+ url = match.group(3)
107
+ if _DANGEROUS_PROTOCOL.match(url):
108
+ return f"{attr_name}={quote}#{quote}"
109
+ return match.group(0)
110
+
111
+
112
+def sanitize_html(html: str) -> str:
113
+ """Remove dangerous HTML tags and attributes while preserving safe formatting.
114
+
115
+ Strips <script>, <style>, <iframe>, <object>, <embed>, <form>, <base>,
116
+ <meta>, <link> tags and their content. Removes event handler attributes
117
+ (on*) and replaces dangerous URL protocols (javascript:, data:, vbscript:)
118
+ in href/src with '#'.
119
+ """
120
+ if not html:
121
+ return html
122
+
123
+ # 1. Remove dangerous tags WITH their content (e.g. <script>...</script>)
124
+ html = _DANGEROUS_CONTENT_TAGS.sub("", html)
125
+
126
+ # 2. Remove any remaining self-closing or orphaned dangerous tags
127
+ html = _DANGEROUS_SELF_CLOSING.sub("", html)
128
+
129
+ # 3. Remove event handler attributes (onclick, onload, onerror, etc.)
130
+ html = _EVENT_HANDLERS.sub("", html)
131
+
132
+ # 4. Neutralize dangerous URL protocols in href and src attributes
133
+ html = _URL_ATTR.sub(_clean_url_attr, html)
134
+
135
+ return html
--- a/core/sanitize.py
+++ b/core/sanitize.py
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/sanitize.py
+++ b/core/sanitize.py
@@ -0,0 +1,135 @@
1 """HTML sanitization for user-generated content.
2
3 Strips dangerous tags (<script>, <style>, <iframe>, etc.), event handlers (on*),
4 and dangerous URL protocols (javascript:, data:, vbscript:) while preserving
5 safe formatting tags used by Fossil wiki, Markdown, and Pikchr diagrams.
6 """
7
8 import re
9
10 # Tags that are safe to render -- covers Markdown/wiki formatting and Pikchr SVG
11 ALLOWED_TAGS = {
12 "a",
13 "abbr",
14 "acronym",
15 "b",
16 "blockquote",
17 "br",
18 "code",
19 "dd",
20 "del",
21 "details",
22 "div",
23 "dl",
24 "dt",
25 "em",
26 "h1",
27 "h2",
28 "h3",
29 "h4",
30 "h5",
31 "h6",
32 "hr",
33 "i",
34 "img",
35 "ins",
36 "kbd",
37 "li",
38 "mark",
39 "ol",
40 "p",
41 "pre",
42 "q",
43 "s",
44 "samp",
45 "small",
46 "span",
47 "strong",
48 "sub",
49 "summary",
50 "sup",
51 "table",
52 "tbody",
53 "td",
54 "tfoot",
55 "th",
56 "thead",
57 "tr",
58 "tt",
59 "u",
60 "ul",
61 "var",
62 # SVG elements for Pikchr diagrams
63 "svg",
64 "path",
65 "circle",
66 "rect",
67 "line",
68 "polyline",
69 "polygon",
70 "g",
71 "text",
72 "defs",
73 "use",
74 "symbol",
75 }
76
77 # Tags whose entire content (not just the tag) must be removed
78 _DANGEROUS_CONTENT_TAGS = re.compile(
79 r"<\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>",
80 re.IGNORECASE | re.DOTALL,
81 )
82
83 # Self-closing / unclosed dangerous tags
84 _DANGEROUS_SELF_CLOSING = re.compile(
85 r"<\s*/?\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*/?\s*>",
86 re.IGNORECASE,
87 )
88
89 # Event handler attributes (onclick, onload, onerror, etc.)
90 _EVENT_HANDLERS = re.compile(
91 r"""\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)""",
92 re.IGNORECASE,
93 )
94
95 # Dangerous protocols in href/src values
96 _DANGEROUS_PROTOCOL = re.compile(r"^\s*(?:javascript|vbscript|data):", re.IGNORECASE)
97
98 # href="..." and src="..." attribute pattern
99 _URL_ATTR = re.compile(r"""(href|src)\s*=\s*(["']?)([^"'>\s]+)\2""", re.IGNORECASE)
100
101
102 def _clean_url_attr(match: re.Match) -> str:
103 """Replace dangerous protocol URLs with a safe '#' anchor."""
104 attr_name = match.group(1)
105 quote = match.group(2) or ""
106 url = match.group(3)
107 if _DANGEROUS_PROTOCOL.match(url):
108 return f"{attr_name}={quote}#{quote}"
109 return match.group(0)
110
111
112 def sanitize_html(html: str) -> str:
113 """Remove dangerous HTML tags and attributes while preserving safe formatting.
114
115 Strips <script>, <style>, <iframe>, <object>, <embed>, <form>, <base>,
116 <meta>, <link> tags and their content. Removes event handler attributes
117 (on*) and replaces dangerous URL protocols (javascript:, data:, vbscript:)
118 in href/src with '#'.
119 """
120 if not html:
121 return html
122
123 # 1. Remove dangerous tags WITH their content (e.g. <script>...</script>)
124 html = _DANGEROUS_CONTENT_TAGS.sub("", html)
125
126 # 2. Remove any remaining self-closing or orphaned dangerous tags
127 html = _DANGEROUS_SELF_CLOSING.sub("", html)
128
129 # 3. Remove event handler attributes (onclick, onload, onerror, etc.)
130 html = _EVENT_HANDLERS.sub("", html)
131
132 # 4. Neutralize dangerous URL protocols in href and src attributes
133 html = _URL_ATTR.sub(_clean_url_attr, html)
134
135 return html
--- core/templatetags/permissions_tags.py
+++ core/templatetags/permissions_tags.py
@@ -3,11 +3,11 @@
33
register = template.Library()
44
55
66
@register.simple_tag(takes_context=True)
77
def has_perm(context, perm_string):
8
- """Check if the current user has a specific permission. Usage: {% has_perm 'items.view_item' as can_view %}"""
8
+ """Check if the current user has a specific permission. Usage: {% has_perm 'projects.view_project' as can_view %}"""
99
user = context.get("user") or context["request"].user
1010
if not user or not user.is_authenticated:
1111
return False
1212
if user.is_superuser:
1313
return True
1414
--- core/templatetags/permissions_tags.py
+++ core/templatetags/permissions_tags.py
@@ -3,11 +3,11 @@
3 register = template.Library()
4
5
6 @register.simple_tag(takes_context=True)
7 def has_perm(context, perm_string):
8 """Check if the current user has a specific permission. Usage: {% has_perm 'items.view_item' as can_view %}"""
9 user = context.get("user") or context["request"].user
10 if not user or not user.is_authenticated:
11 return False
12 if user.is_superuser:
13 return True
14
--- core/templatetags/permissions_tags.py
+++ core/templatetags/permissions_tags.py
@@ -3,11 +3,11 @@
3 register = template.Library()
4
5
6 @register.simple_tag(takes_context=True)
7 def has_perm(context, perm_string):
8 """Check if the current user has a specific permission. Usage: {% has_perm 'projects.view_project' as can_view %}"""
9 user = context.get("user") or context["request"].user
10 if not user or not user.is_authenticated:
11 return False
12 if user.is_superuser:
13 return True
14
+32 -28
--- core/tests.py
+++ core/tests.py
@@ -8,62 +8,66 @@
88
99
class TrackingModelTest(TestCase):
1010
"""Test the Tracking abstract model via a concrete model that uses it."""
1111
1212
def setUp(self):
13
- from items.models import Item
13
+ from organization.models import Organization
14
+ from projects.models import Project
1415
1516
self.user = User.objects.create_superuser(username="test", password="x")
16
- self.item = Item.objects.create(name="Test Widget", price="9.99", created_by=self.user)
17
+ self.org = Organization.objects.create(name="Test Org", created_by=self.user)
18
+ self.project = Project.objects.create(name="Test Project", organization=self.org, created_by=self.user)
1719
1820
def test_version_increments_on_save(self):
19
- initial_version = self.item.version
20
- self.item.name = "Updated Widget"
21
- self.item.save()
22
- self.item.refresh_from_db()
23
- self.assertEqual(self.item.version, initial_version + 1)
21
+ initial_version = self.project.version
22
+ self.project.name = "Updated Project"
23
+ self.project.save()
24
+ self.project.refresh_from_db()
25
+ self.assertEqual(self.project.version, initial_version + 1)
2426
2527
def test_soft_delete_sets_deleted_at(self):
26
- self.item.soft_delete(user=self.user)
27
- self.item.refresh_from_db()
28
- self.assertIsNotNone(self.item.deleted_at)
29
- self.assertEqual(self.item.deleted_by, self.user)
30
- self.assertTrue(self.item.is_deleted)
28
+ self.project.soft_delete(user=self.user)
29
+ self.project.refresh_from_db()
30
+ self.assertIsNotNone(self.project.deleted_at)
31
+ self.assertEqual(self.project.deleted_by, self.user)
32
+ self.assertTrue(self.project.is_deleted)
3133
3234
def test_created_at_auto_set(self):
33
- self.assertIsNotNone(self.item.created_at)
35
+ self.assertIsNotNone(self.project.created_at)
3436
3537
def test_updated_at_auto_set(self):
36
- self.assertIsNotNone(self.item.updated_at)
38
+ self.assertIsNotNone(self.project.updated_at)
3739
3840
3941
class BaseCoreModelTest(TestCase):
4042
"""Test BaseCoreModel slug generation and UUID."""
4143
4244
def setUp(self):
43
- from items.models import Item
45
+ from organization.models import Organization
46
+ from projects.models import Project
4447
4548
self.user = User.objects.create_superuser(username="test", password="x")
46
- self.item = Item.objects.create(name="My Item", price="19.99", created_by=self.user)
49
+ self.org = Organization.objects.create(name="Test Org", created_by=self.user)
50
+ self.project = Project.objects.create(name="My Project", organization=self.org, created_by=self.user)
4751
4852
def test_slug_auto_generated(self):
49
- self.assertEqual(self.item.slug, "my-item")
53
+ self.assertEqual(self.project.slug, "my-project")
5054
5155
def test_guid_is_uuid(self):
5256
import uuid
5357
54
- self.assertIsInstance(self.item.guid, uuid.UUID)
58
+ self.assertIsInstance(self.project.guid, uuid.UUID)
5559
5660
def test_slug_uniqueness(self):
57
- from items.models import Item
61
+ from projects.models import Project
5862
59
- p2 = Item.objects.create(name="My Item", price="29.99", created_by=self.user)
60
- self.assertNotEqual(self.item.slug, p2.slug)
61
- self.assertTrue(p2.slug.startswith("my-item"))
63
+ p2 = Project.objects.create(name="My Project", organization=self.org, created_by=self.user)
64
+ self.assertNotEqual(self.project.slug, p2.slug)
65
+ self.assertTrue(p2.slug.startswith("my-project"))
6266
6367
def test_str_returns_name(self):
64
- self.assertEqual(str(self.item), "My Item")
68
+ self.assertEqual(str(self.project), "My Project")
6569
6670
6771
class PermissionsTest(TestCase):
6872
"""Test the P permission enum."""
6973
@@ -70,28 +74,28 @@
7074
def setUp(self):
7175
self.superuser = User.objects.create_superuser(username="super", password="x")
7276
self.regular = User.objects.create_user(username="regular", password="x")
7377
7478
def test_superuser_passes_all_checks(self):
75
- self.assertTrue(P.ITEM_VIEW.check(self.superuser))
76
- self.assertTrue(P.ITEM_ADD.check(self.superuser))
79
+ self.assertTrue(P.PROJECT_VIEW.check(self.superuser))
80
+ self.assertTrue(P.PROJECT_ADD.check(self.superuser))
7781
7882
def test_regular_user_without_perm_denied(self):
7983
from django.core.exceptions import PermissionDenied
8084
8185
with self.assertRaises(PermissionDenied):
82
- P.ITEM_ADD.check(self.regular)
86
+ P.PROJECT_ADD.check(self.regular)
8387
8488
def test_regular_user_without_perm_returns_false(self):
85
- self.assertFalse(P.ITEM_ADD.check(self.regular, raise_error=False))
89
+ self.assertFalse(P.PROJECT_ADD.check(self.regular, raise_error=False))
8690
8791
def test_unauthenticated_user_denied(self):
8892
from django.contrib.auth.models import AnonymousUser
8993
from django.core.exceptions import PermissionDenied
9094
9195
with self.assertRaises(PermissionDenied):
92
- P.ITEM_VIEW.check(AnonymousUser())
96
+ P.PROJECT_VIEW.check(AnonymousUser())
9397
9498
9599
@pytest.mark.django_db
96100
class TestDashboard:
97101
def test_dashboard_requires_login(self, client):
98102
--- core/tests.py
+++ core/tests.py
@@ -8,62 +8,66 @@
8
9 class TrackingModelTest(TestCase):
10 """Test the Tracking abstract model via a concrete model that uses it."""
11
12 def setUp(self):
13 from items.models import Item
 
14
15 self.user = User.objects.create_superuser(username="test", password="x")
16 self.item = Item.objects.create(name="Test Widget", price="9.99", created_by=self.user)
 
17
18 def test_version_increments_on_save(self):
19 initial_version = self.item.version
20 self.item.name = "Updated Widget"
21 self.item.save()
22 self.item.refresh_from_db()
23 self.assertEqual(self.item.version, initial_version + 1)
24
25 def test_soft_delete_sets_deleted_at(self):
26 self.item.soft_delete(user=self.user)
27 self.item.refresh_from_db()
28 self.assertIsNotNone(self.item.deleted_at)
29 self.assertEqual(self.item.deleted_by, self.user)
30 self.assertTrue(self.item.is_deleted)
31
32 def test_created_at_auto_set(self):
33 self.assertIsNotNone(self.item.created_at)
34
35 def test_updated_at_auto_set(self):
36 self.assertIsNotNone(self.item.updated_at)
37
38
39 class BaseCoreModelTest(TestCase):
40 """Test BaseCoreModel slug generation and UUID."""
41
42 def setUp(self):
43 from items.models import Item
 
44
45 self.user = User.objects.create_superuser(username="test", password="x")
46 self.item = Item.objects.create(name="My Item", price="19.99", created_by=self.user)
 
47
48 def test_slug_auto_generated(self):
49 self.assertEqual(self.item.slug, "my-item")
50
51 def test_guid_is_uuid(self):
52 import uuid
53
54 self.assertIsInstance(self.item.guid, uuid.UUID)
55
56 def test_slug_uniqueness(self):
57 from items.models import Item
58
59 p2 = Item.objects.create(name="My Item", price="29.99", created_by=self.user)
60 self.assertNotEqual(self.item.slug, p2.slug)
61 self.assertTrue(p2.slug.startswith("my-item"))
62
63 def test_str_returns_name(self):
64 self.assertEqual(str(self.item), "My Item")
65
66
67 class PermissionsTest(TestCase):
68 """Test the P permission enum."""
69
@@ -70,28 +74,28 @@
70 def setUp(self):
71 self.superuser = User.objects.create_superuser(username="super", password="x")
72 self.regular = User.objects.create_user(username="regular", password="x")
73
74 def test_superuser_passes_all_checks(self):
75 self.assertTrue(P.ITEM_VIEW.check(self.superuser))
76 self.assertTrue(P.ITEM_ADD.check(self.superuser))
77
78 def test_regular_user_without_perm_denied(self):
79 from django.core.exceptions import PermissionDenied
80
81 with self.assertRaises(PermissionDenied):
82 P.ITEM_ADD.check(self.regular)
83
84 def test_regular_user_without_perm_returns_false(self):
85 self.assertFalse(P.ITEM_ADD.check(self.regular, raise_error=False))
86
87 def test_unauthenticated_user_denied(self):
88 from django.contrib.auth.models import AnonymousUser
89 from django.core.exceptions import PermissionDenied
90
91 with self.assertRaises(PermissionDenied):
92 P.ITEM_VIEW.check(AnonymousUser())
93
94
95 @pytest.mark.django_db
96 class TestDashboard:
97 def test_dashboard_requires_login(self, client):
98
--- core/tests.py
+++ core/tests.py
@@ -8,62 +8,66 @@
8
9 class TrackingModelTest(TestCase):
10 """Test the Tracking abstract model via a concrete model that uses it."""
11
12 def setUp(self):
13 from organization.models import Organization
14 from projects.models import Project
15
16 self.user = User.objects.create_superuser(username="test", password="x")
17 self.org = Organization.objects.create(name="Test Org", created_by=self.user)
18 self.project = Project.objects.create(name="Test Project", organization=self.org, created_by=self.user)
19
20 def test_version_increments_on_save(self):
21 initial_version = self.project.version
22 self.project.name = "Updated Project"
23 self.project.save()
24 self.project.refresh_from_db()
25 self.assertEqual(self.project.version, initial_version + 1)
26
27 def test_soft_delete_sets_deleted_at(self):
28 self.project.soft_delete(user=self.user)
29 self.project.refresh_from_db()
30 self.assertIsNotNone(self.project.deleted_at)
31 self.assertEqual(self.project.deleted_by, self.user)
32 self.assertTrue(self.project.is_deleted)
33
34 def test_created_at_auto_set(self):
35 self.assertIsNotNone(self.project.created_at)
36
37 def test_updated_at_auto_set(self):
38 self.assertIsNotNone(self.project.updated_at)
39
40
41 class BaseCoreModelTest(TestCase):
42 """Test BaseCoreModel slug generation and UUID."""
43
44 def setUp(self):
45 from organization.models import Organization
46 from projects.models import Project
47
48 self.user = User.objects.create_superuser(username="test", password="x")
49 self.org = Organization.objects.create(name="Test Org", created_by=self.user)
50 self.project = Project.objects.create(name="My Project", organization=self.org, created_by=self.user)
51
52 def test_slug_auto_generated(self):
53 self.assertEqual(self.project.slug, "my-project")
54
55 def test_guid_is_uuid(self):
56 import uuid
57
58 self.assertIsInstance(self.project.guid, uuid.UUID)
59
60 def test_slug_uniqueness(self):
61 from projects.models import Project
62
63 p2 = Project.objects.create(name="My Project", organization=self.org, created_by=self.user)
64 self.assertNotEqual(self.project.slug, p2.slug)
65 self.assertTrue(p2.slug.startswith("my-project"))
66
67 def test_str_returns_name(self):
68 self.assertEqual(str(self.project), "My Project")
69
70
71 class PermissionsTest(TestCase):
72 """Test the P permission enum."""
73
@@ -70,28 +74,28 @@
74 def setUp(self):
75 self.superuser = User.objects.create_superuser(username="super", password="x")
76 self.regular = User.objects.create_user(username="regular", password="x")
77
78 def test_superuser_passes_all_checks(self):
79 self.assertTrue(P.PROJECT_VIEW.check(self.superuser))
80 self.assertTrue(P.PROJECT_ADD.check(self.superuser))
81
82 def test_regular_user_without_perm_denied(self):
83 from django.core.exceptions import PermissionDenied
84
85 with self.assertRaises(PermissionDenied):
86 P.PROJECT_ADD.check(self.regular)
87
88 def test_regular_user_without_perm_returns_false(self):
89 self.assertFalse(P.PROJECT_ADD.check(self.regular, raise_error=False))
90
91 def test_unauthenticated_user_denied(self):
92 from django.contrib.auth.models import AnonymousUser
93 from django.core.exceptions import PermissionDenied
94
95 with self.assertRaises(PermissionDenied):
96 P.PROJECT_VIEW.check(AnonymousUser())
97
98
99 @pytest.mark.django_db
100 class TestDashboard:
101 def test_dashboard_requires_login(self, client):
102
--- core/views.py
+++ core/views.py
@@ -14,12 +14,16 @@
1414
total_projects = Project.objects.count()
1515
total_checkins = 0
1616
total_tickets = 0
1717
total_wiki = 0
1818
system_activity = [] # weekly commit counts across all repos
19
+ heatmap_data = {} # {date_string: count} -- daily commit counts across all repos
1920
recent_across_all = []
2021
22
+ # NOTE: For large installations with many repos, this per-request aggregation
23
+ # could become slow. Consider caching heatmap_data with a short TTL (e.g. 5 min)
24
+ # via Django's cache framework if this becomes a bottleneck.
2125
repos = FossilRepository.objects.filter(deleted_at__isnull=True)
2226
for repo in repos:
2327
if not repo.exists_on_disk:
2428
continue
2529
try:
@@ -35,10 +39,16 @@
3539
else:
3640
for i, c in enumerate(activity):
3741
if i < len(system_activity):
3842
system_activity[i] += c["count"]
3943
44
+ # Aggregate daily activity for heatmap (single pass per repo)
45
+ daily = reader.get_daily_commit_activity(days=365)
46
+ for entry in daily:
47
+ date = entry["date"]
48
+ heatmap_data[date] = heatmap_data.get(date, 0) + entry["count"]
49
+
4050
commits = reader.get_timeline(limit=3, event_type="ci")
4151
for c in commits:
4252
recent_across_all.append({"project": repo.project, "entry": c})
4353
except Exception:
4454
continue
@@ -45,10 +55,13 @@
4555
4656
# Sort recent across all by timestamp, take top 10
4757
recent_across_all.sort(key=lambda x: x["entry"].timestamp, reverse=True)
4858
recent_across_all = recent_across_all[:10]
4959
60
+ # Convert heatmap to sorted list for the template
61
+ heatmap_json = json.dumps(sorted([{"date": d, "count": c} for d, c in heatmap_data.items()], key=lambda x: x["date"]))
62
+
5063
return render(
5164
request,
5265
"dashboard.html",
5366
{
5467
"total_projects": total_projects,
@@ -55,8 +68,9 @@
5568
"total_checkins": total_checkins,
5669
"total_tickets": total_tickets,
5770
"total_wiki": total_wiki,
5871
"total_repos": repos.count(),
5972
"system_activity_json": json.dumps(system_activity),
73
+ "heatmap_json": heatmap_json,
6074
"recent_across_all": recent_across_all,
6175
},
6276
)
6377
--- core/views.py
+++ core/views.py
@@ -14,12 +14,16 @@
14 total_projects = Project.objects.count()
15 total_checkins = 0
16 total_tickets = 0
17 total_wiki = 0
18 system_activity = [] # weekly commit counts across all repos
 
19 recent_across_all = []
20
 
 
 
21 repos = FossilRepository.objects.filter(deleted_at__isnull=True)
22 for repo in repos:
23 if not repo.exists_on_disk:
24 continue
25 try:
@@ -35,10 +39,16 @@
35 else:
36 for i, c in enumerate(activity):
37 if i < len(system_activity):
38 system_activity[i] += c["count"]
39
 
 
 
 
 
 
40 commits = reader.get_timeline(limit=3, event_type="ci")
41 for c in commits:
42 recent_across_all.append({"project": repo.project, "entry": c})
43 except Exception:
44 continue
@@ -45,10 +55,13 @@
45
46 # Sort recent across all by timestamp, take top 10
47 recent_across_all.sort(key=lambda x: x["entry"].timestamp, reverse=True)
48 recent_across_all = recent_across_all[:10]
49
 
 
 
50 return render(
51 request,
52 "dashboard.html",
53 {
54 "total_projects": total_projects,
@@ -55,8 +68,9 @@
55 "total_checkins": total_checkins,
56 "total_tickets": total_tickets,
57 "total_wiki": total_wiki,
58 "total_repos": repos.count(),
59 "system_activity_json": json.dumps(system_activity),
 
60 "recent_across_all": recent_across_all,
61 },
62 )
63
--- core/views.py
+++ core/views.py
@@ -14,12 +14,16 @@
14 total_projects = Project.objects.count()
15 total_checkins = 0
16 total_tickets = 0
17 total_wiki = 0
18 system_activity = [] # weekly commit counts across all repos
19 heatmap_data = {} # {date_string: count} -- daily commit counts across all repos
20 recent_across_all = []
21
22 # NOTE: For large installations with many repos, this per-request aggregation
23 # could become slow. Consider caching heatmap_data with a short TTL (e.g. 5 min)
24 # via Django's cache framework if this becomes a bottleneck.
25 repos = FossilRepository.objects.filter(deleted_at__isnull=True)
26 for repo in repos:
27 if not repo.exists_on_disk:
28 continue
29 try:
@@ -35,10 +39,16 @@
39 else:
40 for i, c in enumerate(activity):
41 if i < len(system_activity):
42 system_activity[i] += c["count"]
43
44 # Aggregate daily activity for heatmap (single pass per repo)
45 daily = reader.get_daily_commit_activity(days=365)
46 for entry in daily:
47 date = entry["date"]
48 heatmap_data[date] = heatmap_data.get(date, 0) + entry["count"]
49
50 commits = reader.get_timeline(limit=3, event_type="ci")
51 for c in commits:
52 recent_across_all.append({"project": repo.project, "entry": c})
53 except Exception:
54 continue
@@ -45,10 +55,13 @@
55
56 # Sort recent across all by timestamp, take top 10
57 recent_across_all.sort(key=lambda x: x["entry"].timestamp, reverse=True)
58 recent_across_all = recent_across_all[:10]
59
60 # Convert heatmap to sorted list for the template
61 heatmap_json = json.dumps(sorted([{"date": d, "count": c} for d, c in heatmap_data.items()], key=lambda x: x["date"]))
62
63 return render(
64 request,
65 "dashboard.html",
66 {
67 "total_projects": total_projects,
@@ -55,8 +68,9 @@
68 "total_checkins": total_checkins,
69 "total_tickets": total_tickets,
70 "total_wiki": total_wiki,
71 "total_repos": repos.count(),
72 "system_activity_json": json.dumps(system_activity),
73 "heatmap_json": heatmap_json,
74 "recent_across_all": recent_across_all,
75 },
76 )
77
+194
--- ctl/main.py
+++ ctl/main.py
@@ -297,5 +297,199 @@
297297
@click.argument("path")
298298
def backup_restore(path: str) -> None:
299299
"""Restore from a backup."""
300300
console.print(f"[bold]Restoring from:[/bold] {path}")
301301
raise NotImplementedError("Restore not yet implemented")
302
+
303
+
304
+# ---------------------------------------------------------------------------
305
+# Bundle commands
306
+# ---------------------------------------------------------------------------
307
+
308
+
309
+@cli.group()
310
+def bundle() -> None:
311
+ """Export and import Fossil repository bundles."""
312
+
313
+
314
+@bundle.command(name="export")
315
+@click.argument("project_slug")
316
+@click.argument("output_path")
317
+def bundle_export(project_slug: str, output_path: str) -> None:
318
+ """Export a Fossil repo as a bundle file."""
319
+ import django
320
+
321
+ django.setup()
322
+
323
+ from fossil.cli import FossilCLI
324
+ from fossil.models import FossilRepository
325
+
326
+ repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
327
+ if not repo:
328
+ console.print(f"[red]No repository found for project: {project_slug}[/red]")
329
+ return
330
+
331
+ if not repo.exists_on_disk:
332
+ console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
333
+ return
334
+
335
+ fossil_cli = FossilCLI()
336
+ if not fossil_cli.is_available():
337
+ console.print("[red]Fossil binary not found.[/red]")
338
+ return
339
+
340
+ output = Path(output_path)
341
+ output.parent.mkdir(parents=True, exist_ok=True)
342
+
343
+ console.print(f"[bold]Exporting bundle:[/bold] {repo.filename} -> {output}")
344
+ try:
345
+ result = subprocess.run(
346
+ [fossil_cli.binary, "bundle", "export", str(output), "-R", str(repo.full_path)],
347
+ capture_output=True,
348
+ text=True,
349
+ timeout=300,
350
+ env=fossil_cli._env,
351
+ )
352
+ if result.returncode == 0:
353
+ size_kb = output.stat().st_size / 1024
354
+ console.print(f" [green]Success[/green] — {size_kb:.0f} KB written to {output}")
355
+ else:
356
+ console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357
+ except subprocess.TimeoutExpired:
358
+ console.print("[red]Export timed out after 5 minutes.[/red]")
359
+
360
+
361
+# ---------------------------------------------------------------------------
362
+# Update commands
363
+# ---------------------------------------------------------------------------
364
+
365
+
366
+@cli.command()
367
+@click.option("--source", type=click.Choice(["auto", "pypi", "git", "docker"]), default="auto", help="Update source.")
368
+def check_update(source: str) -> None:
369
+ """Check for available updates."""
370
+ import importlib.metadata
371
+
372
+ import requests
373
+
374
+ current = importlib.metadata.version("fossilrepo")
375
+ console.print(f"[bold]Current version:[/bold] {current}")
376
+
377
+ if source == "auto":
378
+ # Detect install source
379
+ if (PROJECT_ROOT / ".git").exists():
380
+ source = "git"
381
+ elif COMPOSE_FILE.exists():
382
+ source = "docker"
383
+ else:
384
+ source = "pypi"
385
+
386
+ latest = None
387
+ if source == "pypi":
388
+ console.print("[dim]Checking PyPI...[/dim]")
389
+ try:
390
+ resp = requests.get("https://pypi.org/pypi/fossilrepo/json", timeout=10)
391
+ if resp.status_code == 200:
392
+ latest = resp.json()["info"]["version"]
393
+ except Exception:
394
+ console.print("[yellow]Could not reach PyPI[/yellow]")
395
+
396
+ elif source == "git":
397
+ console.print("[dim]Checking GitHub releases...[/dim]")
398
+ try:
399
+ resp = requests.get("https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest", timeout=10)
400
+ if resp.status_code == 200:
401
+ latest = resp.json()["tag_name"].lstrip("v")
402
+ except Exception:
403
+ console.print("[yellow]Could not reach GitHub[/yellow]")
404
+
405
+ elif source == "docker":
406
+ console.print("[dim]Checking Docker Hub...[/dim]")
407
+ try:
408
+ resp = requests.get("https://hub.docker.com/v2/repositories/conflicthq/fossilrepo/tags/latest", timeout=10)
409
+ if resp.status_code == 200:
410
+ latest = resp.json().get("name", "unknown")
411
+ except Exception:
412
+ console.print("[yellow]Could not reach Docker Hub[/yellow]")
413
+
414
+ if latest:
415
+ if latest != current:
416
+ console.print(f"[bold green]Update available:[/bold green] {current} → {latest} (source: {source})")
417
+ else:
418
+ console.print(f"[green]Up to date.[/green] ({current}, source: {source})")
419
+ else:
420
+ console.print("[yellow]Could not determine latest version.[/yellow]")
421
+
422
+
423
+@cli.command()
424
+@click.option("--source", type=click.Choice(["auto", "pypi", "git"]), default="auto", help="Update source.")
425
+@click.confirmation_option(prompt="This will update fossilrepo and restart services. Continue?")
426
+def update(source: str) -> None:
427
+ """Update fossilrepo to the latest version."""
428
+ if source == "auto":
429
+ if (PROJECT_ROOT / ".git").exists():
430
+ source = "git"
431
+ else:
432
+ source = "pypi"
433
+
434
+ if source == "git":
435
+ console.print("[bold]Pulling latest from git...[/bold]")
436
+ subprocess.run(["git", "pull", "--ff-only"], cwd=str(PROJECT_ROOT), check=True)
437
+ console.print("[bold]Installing dependencies...[/bold]")
438
+ subprocess.run(["pip", "install", "-e", "."], cwd=str(PROJECT_ROOT), check=True)
439
+ elif source == "pypi":
440
+ console.print("[bold]Upgrading from PyPI...[/bold]")
441
+ subprocess.run(["pip", "install", "--upgrade", "fossilrepo"], check=True)
442
+
443
+ console.print("[bold]Running migrations...[/bold]")
444
+ subprocess.run(["python", "manage.py", "migrate", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
445
+ console.print("[bold]Collecting static files...[/bold]")
446
+ subprocess.run(["python", "manage.py", "collectstatic", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
447
+ console.print("[bold green]Update complete. Restart services to apply.[/bold green]")
448
+
449
+
450
+@bundle.command(name="import")
451
+@click.argument("project_slug")
452
+@click.argument("input_path")
453
+def bundle_import(project_slug: str, input_path: str) -> None:
454
+ """Import a Fossil bundle into an existing repo."""
455
+ import django
456
+
457
+ django.setup()
458
+
459
+ from fossil.cli import FossilCLI
460
+ from fossil.models import FossilRepository
461
+
462
+ repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
463
+ if not repo:
464
+ console.print(f"[red]No repository found for project: {project_slug}[/red]")
465
+ return
466
+
467
+ if not repo.exists_on_disk:
468
+ console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
469
+ return
470
+
471
+ input_file = Path(input_path)
472
+ if not input_file.exists():
473
+ console.print(f"[red]Bundle file not found: {input_file}[/red]")
474
+ return
475
+
476
+ fossil_cli = FossilCLI()
477
+ if not fossil_cli.is_available():
478
+ console.print("[red]Fossil binary not found.[/red]")
479
+ return
480
+
481
+ console.print(f"[bold]Importing bundle:[/bold] {input_file} -> {repo.filename}")
482
+ try:
483
+ result = subprocess.run(
484
+ [fossil_cli.binary, "bundle", "import", str(input_file), "-R", str(repo.full_path)],
485
+ capture_output=True,
486
+ text=True,
487
+ timeout=300,
488
+ env=fossil_cli._env,
489
+ )
490
+ if result.returncode == 0:
491
+ console.print(f" [green]Success[/green] — {result.stdout.strip()}")
492
+ else:
493
+ console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
494
+ except subprocess.TimeoutExpired:
495
+ console.print("[red]Import timed out after 5 minutes.[/red]")
302496
--- ctl/main.py
+++ ctl/main.py
@@ -297,5 +297,199 @@
297 @click.argument("path")
298 def backup_restore(path: str) -> None:
299 """Restore from a backup."""
300 console.print(f"[bold]Restoring from:[/bold] {path}")
301 raise NotImplementedError("Restore not yet implemented")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
--- ctl/main.py
+++ ctl/main.py
@@ -297,5 +297,199 @@
297 @click.argument("path")
298 def backup_restore(path: str) -> None:
299 """Restore from a backup."""
300 console.print(f"[bold]Restoring from:[/bold] {path}")
301 raise NotImplementedError("Restore not yet implemented")
302
303
304 # ---------------------------------------------------------------------------
305 # Bundle commands
306 # ---------------------------------------------------------------------------
307
308
309 @cli.group()
310 def bundle() -> None:
311 """Export and import Fossil repository bundles."""
312
313
314 @bundle.command(name="export")
315 @click.argument("project_slug")
316 @click.argument("output_path")
317 def bundle_export(project_slug: str, output_path: str) -> None:
318 """Export a Fossil repo as a bundle file."""
319 import django
320
321 django.setup()
322
323 from fossil.cli import FossilCLI
324 from fossil.models import FossilRepository
325
326 repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
327 if not repo:
328 console.print(f"[red]No repository found for project: {project_slug}[/red]")
329 return
330
331 if not repo.exists_on_disk:
332 console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
333 return
334
335 fossil_cli = FossilCLI()
336 if not fossil_cli.is_available():
337 console.print("[red]Fossil binary not found.[/red]")
338 return
339
340 output = Path(output_path)
341 output.parent.mkdir(parents=True, exist_ok=True)
342
343 console.print(f"[bold]Exporting bundle:[/bold] {repo.filename} -> {output}")
344 try:
345 result = subprocess.run(
346 [fossil_cli.binary, "bundle", "export", str(output), "-R", str(repo.full_path)],
347 capture_output=True,
348 text=True,
349 timeout=300,
350 env=fossil_cli._env,
351 )
352 if result.returncode == 0:
353 size_kb = output.stat().st_size / 1024
354 console.print(f" [green]Success[/green] — {size_kb:.0f} KB written to {output}")
355 else:
356 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357 except subprocess.TimeoutExpired:
358 console.print("[red]Export timed out after 5 minutes.[/red]")
359
360
361 # ---------------------------------------------------------------------------
362 # Update commands
363 # ---------------------------------------------------------------------------
364
365
366 @cli.command()
367 @click.option("--source", type=click.Choice(["auto", "pypi", "git", "docker"]), default="auto", help="Update source.")
368 def check_update(source: str) -> None:
369 """Check for available updates."""
370 import importlib.metadata
371
372 import requests
373
374 current = importlib.metadata.version("fossilrepo")
375 console.print(f"[bold]Current version:[/bold] {current}")
376
377 if source == "auto":
378 # Detect install source
379 if (PROJECT_ROOT / ".git").exists():
380 source = "git"
381 elif COMPOSE_FILE.exists():
382 source = "docker"
383 else:
384 source = "pypi"
385
386 latest = None
387 if source == "pypi":
388 console.print("[dim]Checking PyPI...[/dim]")
389 try:
390 resp = requests.get("https://pypi.org/pypi/fossilrepo/json", timeout=10)
391 if resp.status_code == 200:
392 latest = resp.json()["info"]["version"]
393 except Exception:
394 console.print("[yellow]Could not reach PyPI[/yellow]")
395
396 elif source == "git":
397 console.print("[dim]Checking GitHub releases...[/dim]")
398 try:
399 resp = requests.get("https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest", timeout=10)
400 if resp.status_code == 200:
401 latest = resp.json()["tag_name"].lstrip("v")
402 except Exception:
403 console.print("[yellow]Could not reach GitHub[/yellow]")
404
405 elif source == "docker":
406 console.print("[dim]Checking Docker Hub...[/dim]")
407 try:
408 resp = requests.get("https://hub.docker.com/v2/repositories/conflicthq/fossilrepo/tags/latest", timeout=10)
409 if resp.status_code == 200:
410 latest = resp.json().get("name", "unknown")
411 except Exception:
412 console.print("[yellow]Could not reach Docker Hub[/yellow]")
413
414 if latest:
415 if latest != current:
416 console.print(f"[bold green]Update available:[/bold green] {current} → {latest} (source: {source})")
417 else:
418 console.print(f"[green]Up to date.[/green] ({current}, source: {source})")
419 else:
420 console.print("[yellow]Could not determine latest version.[/yellow]")
421
422
423 @cli.command()
424 @click.option("--source", type=click.Choice(["auto", "pypi", "git"]), default="auto", help="Update source.")
425 @click.confirmation_option(prompt="This will update fossilrepo and restart services. Continue?")
426 def update(source: str) -> None:
427 """Update fossilrepo to the latest version."""
428 if source == "auto":
429 if (PROJECT_ROOT / ".git").exists():
430 source = "git"
431 else:
432 source = "pypi"
433
434 if source == "git":
435 console.print("[bold]Pulling latest from git...[/bold]")
436 subprocess.run(["git", "pull", "--ff-only"], cwd=str(PROJECT_ROOT), check=True)
437 console.print("[bold]Installing dependencies...[/bold]")
438 subprocess.run(["pip", "install", "-e", "."], cwd=str(PROJECT_ROOT), check=True)
439 elif source == "pypi":
440 console.print("[bold]Upgrading from PyPI...[/bold]")
441 subprocess.run(["pip", "install", "--upgrade", "fossilrepo"], check=True)
442
443 console.print("[bold]Running migrations...[/bold]")
444 subprocess.run(["python", "manage.py", "migrate", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
445 console.print("[bold]Collecting static files...[/bold]")
446 subprocess.run(["python", "manage.py", "collectstatic", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
447 console.print("[bold green]Update complete. Restart services to apply.[/bold green]")
448
449
450 @bundle.command(name="import")
451 @click.argument("project_slug")
452 @click.argument("input_path")
453 def bundle_import(project_slug: str, input_path: str) -> None:
454 """Import a Fossil bundle into an existing repo."""
455 import django
456
457 django.setup()
458
459 from fossil.cli import FossilCLI
460 from fossil.models import FossilRepository
461
462 repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
463 if not repo:
464 console.print(f"[red]No repository found for project: {project_slug}[/red]")
465 return
466
467 if not repo.exists_on_disk:
468 console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
469 return
470
471 input_file = Path(input_path)
472 if not input_file.exists():
473 console.print(f"[red]Bundle file not found: {input_file}[/red]")
474 return
475
476 fossil_cli = FossilCLI()
477 if not fossil_cli.is_available():
478 console.print("[red]Fossil binary not found.[/red]")
479 return
480
481 console.print(f"[bold]Importing bundle:[/bold] {input_file} -> {repo.filename}")
482 try:
483 result = subprocess.run(
484 [fossil_cli.binary, "bundle", "import", str(input_file), "-R", str(repo.full_path)],
485 capture_output=True,
486 text=True,
487 timeout=300,
488 env=fossil_cli._env,
489 )
490 if result.returncode == 0:
491 console.print(f" [green]Success[/green] — {result.stdout.strip()}")
492 else:
493 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
494 except subprocess.TimeoutExpired:
495 console.print("[red]Import timed out after 5 minutes.[/red]")
496
--- docker-compose.yaml
+++ docker-compose.yaml
@@ -2,10 +2,11 @@
22
backend:
33
build: .
44
command: python manage.py runserver 0.0.0.0:8000
55
ports:
66
- "8000:8000"
7
+ - "2222:2222"
78
env_file: .env.example
89
environment:
910
DJANGO_DEBUG: "true"
1011
POSTGRES_HOST: postgres
1112
REDIS_URL: redis://redis:6379/1
1213
1314
ADDED docker/entrypoint.sh
1415
ADDED docker/fossil-shell
1516
ADDED docker/litestream-ecs.yml
1617
ADDED docker/sshd_config
1718
DELETED fossil-platform/Dockerfile
1819
DELETED fossil-platform/README.md
--- docker-compose.yaml
+++ docker-compose.yaml
@@ -2,10 +2,11 @@
2 backend:
3 build: .
4 command: python manage.py runserver 0.0.0.0:8000
5 ports:
6 - "8000:8000"
 
7 env_file: .env.example
8 environment:
9 DJANGO_DEBUG: "true"
10 POSTGRES_HOST: postgres
11 REDIS_URL: redis://redis:6379/1
12
13 DDED docker/entrypoint.sh
14 DDED docker/fossil-shell
15 DDED docker/litestream-ecs.yml
16 DDED docker/sshd_config
17 ELETED fossil-platform/Dockerfile
18 ELETED fossil-platform/README.md
--- docker-compose.yaml
+++ docker-compose.yaml
@@ -2,10 +2,11 @@
2 backend:
3 build: .
4 command: python manage.py runserver 0.0.0.0:8000
5 ports:
6 - "8000:8000"
7 - "2222:2222"
8 env_file: .env.example
9 environment:
10 DJANGO_DEBUG: "true"
11 POSTGRES_HOST: postgres
12 REDIS_URL: redis://redis:6379/1
13
14 DDED docker/entrypoint.sh
15 DDED docker/fossil-shell
16 DDED docker/litestream-ecs.yml
17 DDED docker/sshd_config
18 ELETED fossil-platform/Dockerfile
19 ELETED fossil-platform/README.md
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -0,0 +1,40 @@
1
+#!/bin/bash
2
+# fossilrepo entrypoint — starts sshd as root, drops to app user for gunicorn.
3
+#
4
+# sshd needs root for port binding and key access.
5
+# gunicorn runs as the unprivileged 'app' user.
6
+
7
+set -euo pipefail
8
+
9
+# Ensure SSH host keys exist (persistent across restarts via volume)
10
+if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then
11
+ ssh-keygen -A
12
+fi
13
+
14
+# Ensure data dirs exist with correct permissions
15
+mkdir -p /data/ssh /data/repos /data/trash
16
+touch /data/ssh/authorized_keys
17
+chmod 600 /data/ssh/authorized_keys
18
+chown -R fossil:fossil /data/ssh
19
+chown -R app:app /data/repos /data/trash
20
+# fossil user needs read access to repos for SSH sync
21
+chmod -R g+r /data/repos
22
+
23
+# Start sshd in the background (runs as root)
24
+/usr/sbin/sshd -p 2222 -e &
25
+SSHD_PID=$!
26
+echo "sshd started (PID $SSHD_PID) on port 2222"
27
+
28
+# Trap signals to clean up sshd
29
+cleanup() {
30
+ echo "Shutting down sshd..."
31
+ kill "$SSHD_PID" 2>/dev/null || true
32
+ wait "$SSHD_PID" 2>/dev/null || true
33
+}
34
+trap cleanup EXIT TERM INT
35
+
36
+# Drop to non-root 'app' user for gunicorn
37
+exec gosu app gunicorn config.wsgi:application \
38
+ --bind 0.0.0.0:8000 \
39
+ --workers 3 \
40
+ --timeout 120
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -0,0 +1,40 @@
1 #!/bin/bash
2 # fossilrepo entrypoint — starts sshd as root, drops to app user for gunicorn.
3 #
4 # sshd needs root for port binding and key access.
5 # gunicorn runs as the unprivileged 'app' user.
6
7 set -euo pipefail
8
9 # Ensure SSH host keys exist (persistent across restarts via volume)
10 if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then
11 ssh-keygen -A
12 fi
13
14 # Ensure data dirs exist with correct permissions
15 mkdir -p /data/ssh /data/repos /data/trash
16 touch /data/ssh/authorized_keys
17 chmod 600 /data/ssh/authorized_keys
18 chown -R fossil:fossil /data/ssh
19 chown -R app:app /data/repos /data/trash
20 # fossil user needs read access to repos for SSH sync
21 chmod -R g+r /data/repos
22
23 # Start sshd in the background (runs as root)
24 /usr/sbin/sshd -p 2222 -e &
25 SSHD_PID=$!
26 echo "sshd started (PID $SSHD_PID) on port 2222"
27
28 # Trap signals to clean up sshd
29 cleanup() {
30 echo "Shutting down sshd..."
31 kill "$SSHD_PID" 2>/dev/null || true
32 wait "$SSHD_PID" 2>/dev/null || true
33 }
34 trap cleanup EXIT TERM INT
35
36 # Drop to non-root 'app' user for gunicorn
37 exec gosu app gunicorn config.wsgi:application \
38 --bind 0.0.0.0:8000 \
39 --workers 3 \
40 --timeout 120
--- a/docker/fossil-shell
+++ b/docker/fossil-shell
@@ -0,0 +1,65 @@
1
+#!/bin/bash
2
+# fossil-shell — Forced command for SSH-based Fossil clone/push/pull.
3
+#
4
+# Each authorized_keys entry uses:
5
+# command="/usr/local/bin/fossil-shell <username>",no-port-forwarding,...
6
+#
7
+# When a Fossil client connects via SSH, it sends a command like:
8
+# fossil http /path/to/repo.fossil
9
+# which arrives in $SSH_ORIGINAL_COMMAND.
10
+#
11
+# This script:
12
+# 1. Extracts the repo name from the SSH command
13
+# 2. Maps it to the on-disk .fossil file
14
+# 3. Runs fossil http in CGI mode with --localauth
15
+#
16
+# Auth is already handled by the SSH key → user mapping in authorized_keys.
17
+
18
+set -euo pipefail
19
+
20
+FOSSIL_USER="${1:-anonymous}"
21
+REPO_DIR="${FOSSIL_DATA_DIR:-/data/repos}"
22
+
23
+# Validate SSH_ORIGINAL_COMMAND
24
+if [ -z "${SSH_ORIGINAL_COMMAND:-}" ]; then
25
+ echo "Error: Interactive SSH sessions are not supported." >&2
26
+ echo "Use: fossil clone ssh://fossil@<host>/<project-slug> local.fossil" >&2
27
+ exit 1
28
+fi
29
+
30
+# Fossil SSH sends: fossil http <repo-path> --args...
31
+# We only allow "fossil http" commands.
32
+if ! echo "$SSH_ORIGINAL_COMMAND" | grep -qE '^fossil\s+http\s+'; then
33
+ echo "Error: Only fossil http commands are allowed." >&2
34
+ exit 1
35
+fi
36
+
37
+# Extract the repo identifier (second argument after "fossil http")
38
+REPO_ARG=$(echo "$SSH_ORIGINAL_COMMAND" | awk '{print $3}')
39
+
40
+if [ -z "$REPO_ARG" ]; then
41
+ echo "Error: No repository specified." >&2
42
+ exit 1
43
+fi
44
+
45
+# Strip any path components — only allow bare slugs or slug.fossil
46
+REPO_NAME=$(basename "$REPO_ARG" .fossil)
47
+
48
+# Sanitize: only allow alphanumeric, hyphens, underscores
49
+if ! echo "$REPO_NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
50
+ echo "Error: Invalid repository name." >&2
51
+ exit 1
52
+fi
53
+
54
+REPO_PATH="${REPO_DIR}/${REPO_NAME}.fossil"
55
+
56
+if [ ! -f "$REPO_PATH" ]; then
57
+ echo "Error: Repository '${REPO_NAME}' not found." >&2
58
+ exit 1
59
+fi
60
+
61
+# Log the access
62
+logger -t fossil-shell "user=${FOSSIL_USER} repo=${REPO_NAME} action=ssh-sync"
63
+
64
+# Run fossil http in CGI mode
65
+exec fossil http "$REPO_PATH" --localauth
--- a/docker/fossil-shell
+++ b/docker/fossil-shell
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/fossil-shell
+++ b/docker/fossil-shell
@@ -0,0 +1,65 @@
1 #!/bin/bash
2 # fossil-shell — Forced command for SSH-based Fossil clone/push/pull.
3 #
4 # Each authorized_keys entry uses:
5 # command="/usr/local/bin/fossil-shell <username>",no-port-forwarding,...
6 #
7 # When a Fossil client connects via SSH, it sends a command like:
8 # fossil http /path/to/repo.fossil
9 # which arrives in $SSH_ORIGINAL_COMMAND.
10 #
11 # This script:
12 # 1. Extracts the repo name from the SSH command
13 # 2. Maps it to the on-disk .fossil file
14 # 3. Runs fossil http in CGI mode with --localauth
15 #
16 # Auth is already handled by the SSH key → user mapping in authorized_keys.
17
18 set -euo pipefail
19
20 FOSSIL_USER="${1:-anonymous}"
21 REPO_DIR="${FOSSIL_DATA_DIR:-/data/repos}"
22
23 # Validate SSH_ORIGINAL_COMMAND
24 if [ -z "${SSH_ORIGINAL_COMMAND:-}" ]; then
25 echo "Error: Interactive SSH sessions are not supported." >&2
26 echo "Use: fossil clone ssh://fossil@<host>/<project-slug> local.fossil" >&2
27 exit 1
28 fi
29
30 # Fossil SSH sends: fossil http <repo-path> --args...
31 # We only allow "fossil http" commands.
32 if ! echo "$SSH_ORIGINAL_COMMAND" | grep -qE '^fossil\s+http\s+'; then
33 echo "Error: Only fossil http commands are allowed." >&2
34 exit 1
35 fi
36
37 # Extract the repo identifier (second argument after "fossil http")
38 REPO_ARG=$(echo "$SSH_ORIGINAL_COMMAND" | awk '{print $3}')
39
40 if [ -z "$REPO_ARG" ]; then
41 echo "Error: No repository specified." >&2
42 exit 1
43 fi
44
45 # Strip any path components — only allow bare slugs or slug.fossil
46 REPO_NAME=$(basename "$REPO_ARG" .fossil)
47
48 # Sanitize: only allow alphanumeric, hyphens, underscores
49 if ! echo "$REPO_NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
50 echo "Error: Invalid repository name." >&2
51 exit 1
52 fi
53
54 REPO_PATH="${REPO_DIR}/${REPO_NAME}.fossil"
55
56 if [ ! -f "$REPO_PATH" ]; then
57 echo "Error: Repository '${REPO_NAME}' not found." >&2
58 exit 1
59 fi
60
61 # Log the access
62 logger -t fossil-shell "user=${FOSSIL_USER} repo=${REPO_NAME} action=ssh-sync"
63
64 # Run fossil http in CGI mode
65 exec fossil http "$REPO_PATH" --localauth
--- a/docker/litestream-ecs.yml
+++ b/docker/litestream-ecs.yml
@@ -0,0 +1,8 @@
1
+# Litestream ECS config — uses IAM task role for S3 auth (no keys needed)
2
+dbs:
3
+ - path: /data/repos/*.fossil
4
+ replicas:
5
+ - type: s3
6
+ bucket: ${AWS_STORAGE_BUCKET_NAME}
7
+ path: litestream
8
+ region: ${AWS_DEFAULT_REGION}
--- a/docker/litestream-ecs.yml
+++ b/docker/litestream-ecs.yml
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
--- a/docker/litestream-ecs.yml
+++ b/docker/litestream-ecs.yml
@@ -0,0 +1,8 @@
1 # Litestream ECS config — uses IAM task role for S3 auth (no keys needed)
2 dbs:
3 - path: /data/repos/*.fossil
4 replicas:
5 - type: s3
6 bucket: ${AWS_STORAGE_BUCKET_NAME}
7 path: litestream
8 region: ${AWS_DEFAULT_REGION}
--- a/docker/sshd_config
+++ b/docker/sshd_config
@@ -0,0 +1,36 @@
1
+# fossilrepo sshd — restricted config for Fossil SSH access.
2
+#
3
+# Only the "fossil" system user can log in, and all connections are forced
4
+# through fossil-shell via authorized_keys command= directives.
5
+
6
+Port 22
7
+ListenAddress 0.0.0.0
8
+
9
+# Host keys (generated on first boot)
10
+HostKey /etc/ssh/ssh_host_ed25519_key
11
+HostKey /etc/ssh/ssh_host_rsa_key
12
+
13
+# Auth
14
+PermitRootLogin no
15
+PasswordAuthentication no
16
+PubkeyAuthentication yes
17
+AuthorizedKeysFile /data/ssh/authorized_keys
18
+
19
+# Only allow the fossil user
20
+AllowUsers fossil
21
+
22
+# Force all fossil-user connections through the restricted shell,
23
+# even if an authorized_keys entry is missing the command= directive.
24
+ForceCommand /usr/local/bin/fossil-shell
25
+
26
+# Disable everything except the sync protocol
27
+PermitTunnel no
28
+AllowTcpForwarding no
29
+X11Forwarding no
30
+AllowAgentForwarding no
31
+GatewayPorts no
32
+PrintMotd no
33
+
34
+# Logging
35
+SyslogFacility AUTH
36
+LogLevel INFO
--- a/docker/sshd_config
+++ b/docker/sshd_config
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/sshd_config
+++ b/docker/sshd_config
@@ -0,0 +1,36 @@
1 # fossilrepo sshd — restricted config for Fossil SSH access.
2 #
3 # Only the "fossil" system user can log in, and all connections are forced
4 # through fossil-shell via authorized_keys command= directives.
5
6 Port 22
7 ListenAddress 0.0.0.0
8
9 # Host keys (generated on first boot)
10 HostKey /etc/ssh/ssh_host_ed25519_key
11 HostKey /etc/ssh/ssh_host_rsa_key
12
13 # Auth
14 PermitRootLogin no
15 PasswordAuthentication no
16 PubkeyAuthentication yes
17 AuthorizedKeysFile /data/ssh/authorized_keys
18
19 # Only allow the fossil user
20 AllowUsers fossil
21
22 # Force all fossil-user connections through the restricted shell,
23 # even if an authorized_keys entry is missing the command= directive.
24 ForceCommand /usr/local/bin/fossil-shell
25
26 # Disable everything except the sync protocol
27 PermitTunnel no
28 AllowTcpForwarding no
29 X11Forwarding no
30 AllowAgentForwarding no
31 GatewayPorts no
32 PrintMotd no
33
34 # Logging
35 SyslogFacility AUTH
36 LogLevel INFO
D fossil-platform/Dockerfile
-29
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -1,29 +0,0 @@
1
-# Use an official Python runtime as a parent image
2
-FROM python:3.9-slim
3
-
4
-# Set environment variables
5
-ENV PYTHONDONTWRITEBYTECODE 1
6
-ENV PYTHONUNBUFFERED 1
7
-
8
-# Set work directory
9
-WORKDIR /app
10
-
11
-# Install system dependencies
12
-RUN apt-get update && apt-get install -y --no-install-recommends \
13
- fossil \
14
- default-mysql-client \
15
- postgresql-client \
16
- && rm -rf /var/lib/apt/lists/*
17
-
18
-# Install Python dependencies
19
-COPY requirements.txt /app/
20
-RUN pip install --upgrade pip && pip install -r requirements.txt
21
-
22
-# Copy project
23
-COPY . /app/
24
-
25
-# Expose port
26
-EXPOSE 5000
27
-
28
-# Run the application
29
-CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -1,29 +0,0 @@
1 # Use an official Python runtime as a parent image
2 FROM python:3.9-slim
3
4 # Set environment variables
5 ENV PYTHONDONTWRITEBYTECODE 1
6 ENV PYTHONUNBUFFERED 1
7
8 # Set work directory
9 WORKDIR /app
10
11 # Install system dependencies
12 RUN apt-get update && apt-get install -y --no-install-recommends \
13 fossil \
14 default-mysql-client \
15 postgresql-client \
16 && rm -rf /var/lib/apt/lists/*
17
18 # Install Python dependencies
19 COPY requirements.txt /app/
20 RUN pip install --upgrade pip && pip install -r requirements.txt
21
22 # Copy project
23 COPY . /app/
24
25 # Expose port
26 EXPOSE 5000
27
28 # Run the application
29 CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -1,29 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
D fossil-platform/README.md
-65
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -1,65 +0,0 @@
1
-# Fossil SCM-based GitHub/GitLab-like Platform
2
-
3
-This project aims to create a GitHub/GitLab-like platform based on Fossil SCM, providing a comprehensive solution for repository management, issue tracking, wikis, user management, and repository analytics.
4
-
5
-## Core Features
6
-
7
-- Version Control: Utilizes Fossil SCM for repository management
8
-- Backend: Flask-based API for interacting with Fossil SCM and managing platform features
9
-- Database: Supports both MySQL and PostgreSQL via feature flags
10
-- Frontend: React-based web interface for viewing repositories, commits, issues, wikis, and user management
11
-- Authentication & Permissions: OAuth (Google, GitHub) and custom JWT-based authentication
12
-- CI/CD Integration: Optional continuous integration (feature flag enabled)
13
-- Notification System: Email notifications and real-time WebSocket updates
14
-- Extensibility: Plugin system for additional features
15
-
16
-## Technical Stack
17
-
18
-- Backend: Flask (Python)
19
-- Frontend: React (JavaScript)
20
-- Database: MySQL/PostgreSQL (configurable via feature flags)
21
-- ORM: SQLAlchemy
22
-- Authentication: OAuth 2.0, JWT
23
-- Real-time Updates: WebSockets
24
-- CI/CD: (Optional, configurable)
25
-
26
-## Feature Flags
27
-
28
-The platform uses feature flags to enable/disable certain functionalities:
29
-
30
-- `DB_TYPE`: Toggle between MySQL and PostgreSQL (e.g., `DB_TYPE=mysql` or `DB_TYPE=postgres`)
31
-- `ENABLE_CICD`: Enable/disable CI/CD integration (e.g., `ENABLE_CICD=true`)
32
-- `ENABLE_NOTIFICATIONS`: Enable/disable real-time WebSocket updates and email notifications (e.g., `ENABLE_NOTIFICATIONS=true`)
33
-- `AUTH_TYPE`: Choose between OAuth-based login or JWT (e.g., `AUTH_TYPE=oauth` or `AUTH_TYPE=jwt`)
34
-
35
-## Getting Started
36
-
37
-1. Clone the repository
38
-2. Set up the backend:
39
- - Install Python dependencies: `pip install -r requirements.txt`
40
- - Configure environment variables for feature flags
41
- - Run the Flask server: `python app.py`
42
-3. Set up the frontend:
43
- - Navigate to the frontend directory: `cd frontend`
44
- - Install npm packages: `npm install`
45
- - Start the React app: `npm start`
46
-
47
-## Development Roadmap
48
-
49
-1. Set up Fossil SCM integration
50
-2. Implement database abstraction with SQLAlchemy
51
-3. Develop core Flask backend API
52
-4. Create basic React frontend
53
-5. Implement authentication and authorization
54
-6. Add notification system
55
-7. Develop plugin system for extensibility
56
-8. Implement CI/CD integration
57
-9. Comprehensive testing and documentation
58
-
59
-## Contributing
60
-
61
-Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
62
-
63
-## License
64
-
65
-This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -1,65 +0,0 @@
1 # Fossil SCM-based GitHub/GitLab-like Platform
2
3 This project aims to create a GitHub/GitLab-like platform based on Fossil SCM, providing a comprehensive solution for repository management, issue tracking, wikis, user management, and repository analytics.
4
5 ## Core Features
6
7 - Version Control: Utilizes Fossil SCM for repository management
8 - Backend: Flask-based API for interacting with Fossil SCM and managing platform features
9 - Database: Supports both MySQL and PostgreSQL via feature flags
10 - Frontend: React-based web interface for viewing repositories, commits, issues, wikis, and user management
11 - Authentication & Permissions: OAuth (Google, GitHub) and custom JWT-based authentication
12 - CI/CD Integration: Optional continuous integration (feature flag enabled)
13 - Notification System: Email notifications and real-time WebSocket updates
14 - Extensibility: Plugin system for additional features
15
16 ## Technical Stack
17
18 - Backend: Flask (Python)
19 - Frontend: React (JavaScript)
20 - Database: MySQL/PostgreSQL (configurable via feature flags)
21 - ORM: SQLAlchemy
22 - Authentication: OAuth 2.0, JWT
23 - Real-time Updates: WebSockets
24 - CI/CD: (Optional, configurable)
25
26 ## Feature Flags
27
28 The platform uses feature flags to enable/disable certain functionalities:
29
30 - `DB_TYPE`: Toggle between MySQL and PostgreSQL (e.g., `DB_TYPE=mysql` or `DB_TYPE=postgres`)
31 - `ENABLE_CICD`: Enable/disable CI/CD integration (e.g., `ENABLE_CICD=true`)
32 - `ENABLE_NOTIFICATIONS`: Enable/disable real-time WebSocket updates and email notifications (e.g., `ENABLE_NOTIFICATIONS=true`)
33 - `AUTH_TYPE`: Choose between OAuth-based login or JWT (e.g., `AUTH_TYPE=oauth` or `AUTH_TYPE=jwt`)
34
35 ## Getting Started
36
37 1. Clone the repository
38 2. Set up the backend:
39 - Install Python dependencies: `pip install -r requirements.txt`
40 - Configure environment variables for feature flags
41 - Run the Flask server: `python app.py`
42 3. Set up the frontend:
43 - Navigate to the frontend directory: `cd frontend`
44 - Install npm packages: `npm install`
45 - Start the React app: `npm start`
46
47 ## Development Roadmap
48
49 1. Set up Fossil SCM integration
50 2. Implement database abstraction with SQLAlchemy
51 3. Develop core Flask backend API
52 4. Create basic React frontend
53 5. Implement authentication and authorization
54 6. Add notification system
55 7. Develop plugin system for extensibility
56 8. Implement CI/CD integration
57 9. Comprehensive testing and documentation
58
59 ## Contributing
60
61 Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
62
63 ## License
64
65 This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -1,65 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,11 +1,24 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
5
+from .agent_claims import TicketClaim
6
+from .api_tokens import APIToken
7
+from .branch_protection import BranchProtection
8
+from .ci import StatusCheck
9
+from .code_reviews import CodeReview, ReviewComment
10
+from .forum import ForumPost
511
from .models import FossilRepository, FossilSnapshot
12
+from .notifications import Notification, NotificationPreference, ProjectWatch
13
+from .releases import Release, ReleaseAsset
614
from .sync_models import GitMirror, SSHKey, SyncLog
15
+from .ticket_fields import TicketFieldDefinition
16
+from .ticket_reports import TicketReport
17
+from .user_keys import UserSSHKey
18
+from .webhooks import Webhook, WebhookDelivery
19
+from .workspaces import AgentWorkspace
720
821
922
class FossilSnapshotInline(admin.TabularInline):
1023
model = FossilSnapshot
1124
extra = 0
@@ -42,5 +55,170 @@
4255
4356
@admin.register(SSHKey)
4457
class SSHKeyAdmin(BaseCoreAdmin):
4558
list_display = ("name", "fingerprint", "created_at")
4659
readonly_fields = ("public_key", "fingerprint")
60
+
61
+
62
+@admin.register(UserSSHKey)
63
+class UserSSHKeyAdmin(BaseCoreAdmin):
64
+ list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at")
65
+ list_filter = ("key_type",)
66
+ search_fields = ("title", "user__username", "fingerprint")
67
+ readonly_fields = ("fingerprint", "key_type")
68
+
69
+
70
+@admin.register(Notification)
71
+class NotificationAdmin(admin.ModelAdmin):
72
+ list_display = ("title", "user", "project", "event_type", "read", "emailed", "created_at")
73
+ list_filter = ("event_type", "read", "emailed")
74
+ search_fields = ("title", "user__username", "project__name")
75
+ raw_id_fields = ("user", "project")
76
+
77
+
78
+@admin.register(ProjectWatch)
79
+class ProjectWatchAdmin(BaseCoreAdmin):
80
+ list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
81
+ list_filter = ("event_filter", "email_enabled")
82
+ search_fields = ("user__username", "project__name")
83
+ raw_id_fields = ("user", "project")
84
+
85
+
86
+@admin.register(NotificationPreference)
87
+class NotificationPreferenceAdmin(admin.ModelAdmin):
88
+ list_display = ("user", "delivery_mode", "notify_checkins", "notify_tickets", "notify_wiki", "notify_releases", "notify_forum")
89
+ list_filter = ("delivery_mode",)
90
+ search_fields = ("user__username",)
91
+ raw_id_fields = ("user",)
92
+
93
+
94
+class ReleaseAssetInline(admin.TabularInline):
95
+ model = ReleaseAsset
96
+ extra = 0
97
+
98
+
99
+@admin.register(Release)
100
+class ReleaseAdmin(BaseCoreAdmin):
101
+ list_display = ("tag_name", "name", "repository", "is_prerelease", "is_draft", "published_at")
102
+ list_filter = ("is_prerelease", "is_draft")
103
+ search_fields = ("tag_name", "name")
104
+ inlines = [ReleaseAssetInline]
105
+
106
+
107
+@admin.register(ReleaseAsset)
108
+class ReleaseAssetAdmin(BaseCoreAdmin):
109
+ list_display = ("name", "release", "file_size_bytes", "download_count")
110
+
111
+
112
+@admin.register(SyncLog)
113
+class SyncLogAdmin(admin.ModelAdmin):
114
+ list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
115
+ list_filter = ("status", "triggered_by")
116
+ search_fields = ("mirror__repository__filename", "message")
117
+ raw_id_fields = ("mirror",)
118
+
119
+
120
+@admin.register(ForumPost)
121
+class ForumPostAdmin(BaseCoreAdmin):
122
+ list_display = ("title", "repository", "parent", "created_by", "created_at")
123
+ search_fields = ("title", "body")
124
+ raw_id_fields = ("repository", "parent", "thread_root")
125
+
126
+
127
+class WebhookDeliveryInline(admin.TabularInline):
128
+ model = WebhookDelivery
129
+ extra = 0
130
+ readonly_fields = ("event_type", "response_status", "success", "delivered_at", "duration_ms", "attempt")
131
+
132
+
133
+@admin.register(Webhook)
134
+class WebhookAdmin(BaseCoreAdmin):
135
+ list_display = ("url", "repository", "events", "is_active", "created_at")
136
+ list_filter = ("is_active", "events")
137
+ search_fields = ("url", "repository__filename")
138
+ raw_id_fields = ("repository",)
139
+ inlines = [WebhookDeliveryInline]
140
+
141
+
142
+@admin.register(WebhookDelivery)
143
+class WebhookDeliveryAdmin(admin.ModelAdmin):
144
+ list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
145
+ list_filter = ("success", "event_type")
146
+ raw_id_fields = ("webhook",)
147
+
148
+
149
+@admin.register(StatusCheck)
150
+class StatusCheckAdmin(BaseCoreAdmin):
151
+ list_display = ("context", "state", "checkin_uuid", "repository", "created_at")
152
+ list_filter = ("state",)
153
+ search_fields = ("context", "checkin_uuid")
154
+ raw_id_fields = ("repository",)
155
+
156
+
157
+@admin.register(APIToken)
158
+class APITokenAdmin(BaseCoreAdmin):
159
+ list_display = ("name", "token_prefix", "repository", "permissions", "last_used_at", "expires_at", "created_at")
160
+ search_fields = ("name", "token_prefix")
161
+ raw_id_fields = ("repository",)
162
+ readonly_fields = ("token_hash", "token_prefix")
163
+
164
+
165
+@admin.register(BranchProtection)
166
+class BranchProtectionAdmin(BaseCoreAdmin):
167
+ list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
168
+ list_filter = ("require_status_checks", "restrict_push")
169
+ search_fields = ("branch_pattern",)
170
+ raw_id_fields = ("repository",)
171
+
172
+
173
+@admin.register(TicketFieldDefinition)
174
+class TicketFieldDefinitionAdmin(BaseCoreAdmin):
175
+ list_display = ("name", "label", "repository", "field_type", "is_required", "sort_order")
176
+ list_filter = ("field_type", "is_required")
177
+ search_fields = ("name", "label")
178
+ raw_id_fields = ("repository",)
179
+
180
+
181
+@admin.register(TicketReport)
182
+class TicketReportAdmin(BaseCoreAdmin):
183
+ list_display = ("title", "repository", "is_public", "created_at")
184
+ list_filter = ("is_public",)
185
+ search_fields = ("title", "description")
186
+ raw_id_fields = ("repository",)
187
+
188
+
189
+@admin.register(AgentWorkspace)
190
+class AgentWorkspaceAdmin(BaseCoreAdmin):
191
+ list_display = ("name", "repository", "branch", "status", "agent_id", "commits_made", "created_at")
192
+ list_filter = ("status",)
193
+ search_fields = ("name", "agent_id", "branch")
194
+ raw_id_fields = ("repository",)
195
+
196
+
197
+@admin.register(TicketClaim)
198
+class TicketClaimAdmin(BaseCoreAdmin):
199
+ list_display = ("ticket_uuid", "repository", "agent_id", "status", "claimed_at", "released_at")
200
+ list_filter = ("status",)
201
+ search_fields = ("ticket_uuid", "agent_id")
202
+ raw_id_fields = ("repository", "workspace")
203
+
204
+
205
+class ReviewCommentInline(admin.TabularInline):
206
+ model = ReviewComment
207
+ extra = 0
208
+ readonly_fields = ("author", "file_path", "line_number", "body", "created_at")
209
+
210
+
211
+@admin.register(CodeReview)
212
+class CodeReviewAdmin(BaseCoreAdmin):
213
+ list_display = ("title", "repository", "status", "agent_id", "created_at")
214
+ list_filter = ("status",)
215
+ search_fields = ("title", "agent_id", "ticket_uuid")
216
+ raw_id_fields = ("repository", "workspace")
217
+ inlines = [ReviewCommentInline]
218
+
219
+
220
+@admin.register(ReviewComment)
221
+class ReviewCommentAdmin(BaseCoreAdmin):
222
+ list_display = ("review", "author", "file_path", "line_number", "created_at")
223
+ search_fields = ("body", "author", "file_path")
224
+ raw_id_fields = ("review",)
47225
48226
ADDED fossil/agent_claims.py
49227
ADDED fossil/api_auth.py
50228
ADDED fossil/api_tokens.py
51229
ADDED fossil/api_views.py
52230
ADDED fossil/branch_protection.py
53231
ADDED fossil/ci.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,11 +1,24 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
 
 
 
 
 
 
5 from .models import FossilRepository, FossilSnapshot
 
 
6 from .sync_models import GitMirror, SSHKey, SyncLog
 
 
 
 
 
7
8
9 class FossilSnapshotInline(admin.TabularInline):
10 model = FossilSnapshot
11 extra = 0
@@ -42,5 +55,170 @@
42
43 @admin.register(SSHKey)
44 class SSHKeyAdmin(BaseCoreAdmin):
45 list_display = ("name", "fingerprint", "created_at")
46 readonly_fields = ("public_key", "fingerprint")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
48 DDED fossil/agent_claims.py
49 DDED fossil/api_auth.py
50 DDED fossil/api_tokens.py
51 DDED fossil/api_views.py
52 DDED fossil/branch_protection.py
53 DDED fossil/ci.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,11 +1,24 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .agent_claims import TicketClaim
6 from .api_tokens import APIToken
7 from .branch_protection import BranchProtection
8 from .ci import StatusCheck
9 from .code_reviews import CodeReview, ReviewComment
10 from .forum import ForumPost
11 from .models import FossilRepository, FossilSnapshot
12 from .notifications import Notification, NotificationPreference, ProjectWatch
13 from .releases import Release, ReleaseAsset
14 from .sync_models import GitMirror, SSHKey, SyncLog
15 from .ticket_fields import TicketFieldDefinition
16 from .ticket_reports import TicketReport
17 from .user_keys import UserSSHKey
18 from .webhooks import Webhook, WebhookDelivery
19 from .workspaces import AgentWorkspace
20
21
22 class FossilSnapshotInline(admin.TabularInline):
23 model = FossilSnapshot
24 extra = 0
@@ -42,5 +55,170 @@
55
56 @admin.register(SSHKey)
57 class SSHKeyAdmin(BaseCoreAdmin):
58 list_display = ("name", "fingerprint", "created_at")
59 readonly_fields = ("public_key", "fingerprint")
60
61
62 @admin.register(UserSSHKey)
63 class UserSSHKeyAdmin(BaseCoreAdmin):
64 list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at")
65 list_filter = ("key_type",)
66 search_fields = ("title", "user__username", "fingerprint")
67 readonly_fields = ("fingerprint", "key_type")
68
69
70 @admin.register(Notification)
71 class NotificationAdmin(admin.ModelAdmin):
72 list_display = ("title", "user", "project", "event_type", "read", "emailed", "created_at")
73 list_filter = ("event_type", "read", "emailed")
74 search_fields = ("title", "user__username", "project__name")
75 raw_id_fields = ("user", "project")
76
77
78 @admin.register(ProjectWatch)
79 class ProjectWatchAdmin(BaseCoreAdmin):
80 list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
81 list_filter = ("event_filter", "email_enabled")
82 search_fields = ("user__username", "project__name")
83 raw_id_fields = ("user", "project")
84
85
86 @admin.register(NotificationPreference)
87 class NotificationPreferenceAdmin(admin.ModelAdmin):
88 list_display = ("user", "delivery_mode", "notify_checkins", "notify_tickets", "notify_wiki", "notify_releases", "notify_forum")
89 list_filter = ("delivery_mode",)
90 search_fields = ("user__username",)
91 raw_id_fields = ("user",)
92
93
94 class ReleaseAssetInline(admin.TabularInline):
95 model = ReleaseAsset
96 extra = 0
97
98
99 @admin.register(Release)
100 class ReleaseAdmin(BaseCoreAdmin):
101 list_display = ("tag_name", "name", "repository", "is_prerelease", "is_draft", "published_at")
102 list_filter = ("is_prerelease", "is_draft")
103 search_fields = ("tag_name", "name")
104 inlines = [ReleaseAssetInline]
105
106
107 @admin.register(ReleaseAsset)
108 class ReleaseAssetAdmin(BaseCoreAdmin):
109 list_display = ("name", "release", "file_size_bytes", "download_count")
110
111
112 @admin.register(SyncLog)
113 class SyncLogAdmin(admin.ModelAdmin):
114 list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
115 list_filter = ("status", "triggered_by")
116 search_fields = ("mirror__repository__filename", "message")
117 raw_id_fields = ("mirror",)
118
119
120 @admin.register(ForumPost)
121 class ForumPostAdmin(BaseCoreAdmin):
122 list_display = ("title", "repository", "parent", "created_by", "created_at")
123 search_fields = ("title", "body")
124 raw_id_fields = ("repository", "parent", "thread_root")
125
126
127 class WebhookDeliveryInline(admin.TabularInline):
128 model = WebhookDelivery
129 extra = 0
130 readonly_fields = ("event_type", "response_status", "success", "delivered_at", "duration_ms", "attempt")
131
132
133 @admin.register(Webhook)
134 class WebhookAdmin(BaseCoreAdmin):
135 list_display = ("url", "repository", "events", "is_active", "created_at")
136 list_filter = ("is_active", "events")
137 search_fields = ("url", "repository__filename")
138 raw_id_fields = ("repository",)
139 inlines = [WebhookDeliveryInline]
140
141
142 @admin.register(WebhookDelivery)
143 class WebhookDeliveryAdmin(admin.ModelAdmin):
144 list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
145 list_filter = ("success", "event_type")
146 raw_id_fields = ("webhook",)
147
148
149 @admin.register(StatusCheck)
150 class StatusCheckAdmin(BaseCoreAdmin):
151 list_display = ("context", "state", "checkin_uuid", "repository", "created_at")
152 list_filter = ("state",)
153 search_fields = ("context", "checkin_uuid")
154 raw_id_fields = ("repository",)
155
156
157 @admin.register(APIToken)
158 class APITokenAdmin(BaseCoreAdmin):
159 list_display = ("name", "token_prefix", "repository", "permissions", "last_used_at", "expires_at", "created_at")
160 search_fields = ("name", "token_prefix")
161 raw_id_fields = ("repository",)
162 readonly_fields = ("token_hash", "token_prefix")
163
164
165 @admin.register(BranchProtection)
166 class BranchProtectionAdmin(BaseCoreAdmin):
167 list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
168 list_filter = ("require_status_checks", "restrict_push")
169 search_fields = ("branch_pattern",)
170 raw_id_fields = ("repository",)
171
172
173 @admin.register(TicketFieldDefinition)
174 class TicketFieldDefinitionAdmin(BaseCoreAdmin):
175 list_display = ("name", "label", "repository", "field_type", "is_required", "sort_order")
176 list_filter = ("field_type", "is_required")
177 search_fields = ("name", "label")
178 raw_id_fields = ("repository",)
179
180
181 @admin.register(TicketReport)
182 class TicketReportAdmin(BaseCoreAdmin):
183 list_display = ("title", "repository", "is_public", "created_at")
184 list_filter = ("is_public",)
185 search_fields = ("title", "description")
186 raw_id_fields = ("repository",)
187
188
189 @admin.register(AgentWorkspace)
190 class AgentWorkspaceAdmin(BaseCoreAdmin):
191 list_display = ("name", "repository", "branch", "status", "agent_id", "commits_made", "created_at")
192 list_filter = ("status",)
193 search_fields = ("name", "agent_id", "branch")
194 raw_id_fields = ("repository",)
195
196
197 @admin.register(TicketClaim)
198 class TicketClaimAdmin(BaseCoreAdmin):
199 list_display = ("ticket_uuid", "repository", "agent_id", "status", "claimed_at", "released_at")
200 list_filter = ("status",)
201 search_fields = ("ticket_uuid", "agent_id")
202 raw_id_fields = ("repository", "workspace")
203
204
205 class ReviewCommentInline(admin.TabularInline):
206 model = ReviewComment
207 extra = 0
208 readonly_fields = ("author", "file_path", "line_number", "body", "created_at")
209
210
211 @admin.register(CodeReview)
212 class CodeReviewAdmin(BaseCoreAdmin):
213 list_display = ("title", "repository", "status", "agent_id", "created_at")
214 list_filter = ("status",)
215 search_fields = ("title", "agent_id", "ticket_uuid")
216 raw_id_fields = ("repository", "workspace")
217 inlines = [ReviewCommentInline]
218
219
220 @admin.register(ReviewComment)
221 class ReviewCommentAdmin(BaseCoreAdmin):
222 list_display = ("review", "author", "file_path", "line_number", "created_at")
223 search_fields = ("body", "author", "file_path")
224 raw_id_fields = ("review",)
225
226 DDED fossil/agent_claims.py
227 DDED fossil/api_auth.py
228 DDED fossil/api_tokens.py
229 DDED fossil/api_views.py
230 DDED fossil/branch_protection.py
231 DDED fossil/ci.py
--- a/fossil/agent_claims.py
+++ b/fossil/agent_claims.py
@@ -0,0 +1,43 @@
1
+"""Agent ticket claim tracking for exclusive work assignment.
2
+
3
+When multiple agents are working on a repository, they need a way to atomically
4
+claim tickets so two agents don't work on the same issue simultaneously.
5
+Claims are Django-side since Fossil tickets live in SQLite.
6
+"""
7
+
8
+from django.db import models
9
+
10
+from core.models import ActiveManager, Tracking
11
+
12
+
13
+class TicketClaim(Tracking):
14
+ """Tracks which agent has claimed a Fossil ticket for exclusive work."""
15
+
16
+ class Status(models.TextChoices):
17
+ CLAIMED = "claimed", "Claimed"
18
+ SUBMITTED = "submitted", "Submitted"
19
+ MERGED = "merged", "Merged"
20
+ RELEASED = "released", "Released"
21
+
22
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="ticket_claims")
23
+ ticket_uuid = models.CharField(max_length=64)
24
+ agent_id = models.CharField(max_length=200)
25
+ workspace = models.ForeignKey("fossil.AgentWorkspace", null=True, blank=True, on_delete=models.SET_NULL, related_name="claims")
26
+ claimed_at = models.DateTimeField(auto_now_add=True)
27
+ released_at = models.DateTimeField(null=True, blank=True)
28
+ status = models.CharField(max_length=20, choices=Status.choices, default=Status.CLAIMED)
29
+ summary = models.TextField(blank=True, default="", help_text="Work summary when submitted")
30
+ files_changed = models.JSONField(default=list, blank=True, help_text="List of files changed")
31
+
32
+ objects = ActiveManager()
33
+ all_objects = models.Manager()
34
+
35
+ class Meta:
36
+ # Uniqueness for active claims is enforced at the application level
37
+ # using select_for_update in the claim endpoint. We cannot use
38
+ # unique_together because soft-deleted rows would violate the
39
+ # constraint when the ticket is reclaimed.
40
+ ordering = ["-claimed_at"]
41
+
42
+ def __str__(self):
43
+ return f"{self.ticket_uuid[:12]} claimed by {self.agent_id}"
--- a/fossil/agent_claims.py
+++ b/fossil/agent_claims.py
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/agent_claims.py
+++ b/fossil/agent_claims.py
@@ -0,0 +1,43 @@
1 """Agent ticket claim tracking for exclusive work assignment.
2
3 When multiple agents are working on a repository, they need a way to atomically
4 claim tickets so two agents don't work on the same issue simultaneously.
5 Claims are Django-side since Fossil tickets live in SQLite.
6 """
7
8 from django.db import models
9
10 from core.models import ActiveManager, Tracking
11
12
13 class TicketClaim(Tracking):
14 """Tracks which agent has claimed a Fossil ticket for exclusive work."""
15
16 class Status(models.TextChoices):
17 CLAIMED = "claimed", "Claimed"
18 SUBMITTED = "submitted", "Submitted"
19 MERGED = "merged", "Merged"
20 RELEASED = "released", "Released"
21
22 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="ticket_claims")
23 ticket_uuid = models.CharField(max_length=64)
24 agent_id = models.CharField(max_length=200)
25 workspace = models.ForeignKey("fossil.AgentWorkspace", null=True, blank=True, on_delete=models.SET_NULL, related_name="claims")
26 claimed_at = models.DateTimeField(auto_now_add=True)
27 released_at = models.DateTimeField(null=True, blank=True)
28 status = models.CharField(max_length=20, choices=Status.choices, default=Status.CLAIMED)
29 summary = models.TextField(blank=True, default="", help_text="Work summary when submitted")
30 files_changed = models.JSONField(default=list, blank=True, help_text="List of files changed")
31
32 objects = ActiveManager()
33 all_objects = models.Manager()
34
35 class Meta:
36 # Uniqueness for active claims is enforced at the application level
37 # using select_for_update in the claim endpoint. We cannot use
38 # unique_together because soft-deleted rows would violate the
39 # constraint when the ticket is reclaimed.
40 ordering = ["-claimed_at"]
41
42 def __str__(self):
43 return f"{self.ticket_uuid[:12]} claimed by {self.agent_id}"
--- a/fossil/api_auth.py
+++ b/fossil/api_auth.py
@@ -0,0 +1,38 @@
1
+"""API authentication for both project-scoped and user-scoped tokens.
2
+
3
+Supports:
4
+1. Project-scoped APIToken (tied to a FossilRepository)
5
+2. U (tied to a Django User)o User) — scopes enforced
6
+3. Session auth fallback (for browser testing)forced on mutating requests
7
+utils import timezone
8
+
9
+
10
+def authenticate_reque):
11
+ """Authenticate an API requn_or_none, error_response_or_none).
12
+ If error_response is not None, return it immediately.
13
+ """
14
+ auth = request.META.get("HTTP_AUTHORIZATION", "")
15
+ if not auth.startswith("Bearer "):
16
+ # Fall back to session auth — session users have fulken-based callers.
17
+}, status=403)
18
+ return request.user, None, None
19
+ return None, None, JsonResponse({"error": "Authentication required"}, status=401)
20
+
21
+ raw_token = auth[7:]
22
+
23
+ # Try project-scoped APIToken first (only if repository is provided)
24
+ if repository:
25
+ from fossil.api_tokens import APIToken
26
+
27
+ token_hash = APIToken.hash_token(raw_token)
28
+ try:
29
+ token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
30
+ if token.expires_at and token.expires_at < timezone.now():
31
+ return None, None, JsonResponse({"error": "Token expired" # No userrn requsspt for token-based callers.
32
+}, status=403)
33
+ return request.user, None, None
34
+ return None, None, JsonResponse({"error": "Authentication required"}, status=401)
35
+
36
+ raw_token = auth[7:]
37
+
38
+ # Try project-sc
--- a/fossil/api_auth.py
+++ b/fossil/api_auth.py
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/api_auth.py
+++ b/fossil/api_auth.py
@@ -0,0 +1,38 @@
1 """API authentication for both project-scoped and user-scoped tokens.
2
3 Supports:
4 1. Project-scoped APIToken (tied to a FossilRepository)
5 2. U (tied to a Django User)o User) — scopes enforced
6 3. Session auth fallback (for browser testing)forced on mutating requests
7 utils import timezone
8
9
10 def authenticate_reque):
11 """Authenticate an API requn_or_none, error_response_or_none).
12 If error_response is not None, return it immediately.
13 """
14 auth = request.META.get("HTTP_AUTHORIZATION", "")
15 if not auth.startswith("Bearer "):
16 # Fall back to session auth — session users have fulken-based callers.
17 }, status=403)
18 return request.user, None, None
19 return None, None, JsonResponse({"error": "Authentication required"}, status=401)
20
21 raw_token = auth[7:]
22
23 # Try project-scoped APIToken first (only if repository is provided)
24 if repository:
25 from fossil.api_tokens import APIToken
26
27 token_hash = APIToken.hash_token(raw_token)
28 try:
29 token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
30 if token.expires_at and token.expires_at < timezone.now():
31 return None, None, JsonResponse({"error": "Token expired" # No userrn requsspt for token-based callers.
32 }, status=403)
33 return request.user, None, None
34 return None, None, JsonResponse({"error": "Authentication required"}, status=401)
35
36 raw_token = auth[7:]
37
38 # Try project-sc
--- a/fossil/api_tokens.py
+++ b/fossil/api_tokens.py
@@ -0,0 +1,70 @@
1
+"""API tokens scoped to a repository for CI/CD and automation.
2
+
3
+Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation
4
+and never stored in plaintext.
5
+"""
6
+
7
+import hashlib
8
+import secrets
9
+
10
+from django.db import models
11
+from django.utils import timezone
12
+
13
+from core.models import ActiveManager, Tracking
14
+
15
+
16
+class APIToken(Tracking):
17
+ """API token scoped to a repository for CI/CD and automation."""
18
+
19
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens")
20
+ name = models.CharField(max_length=200)
21
+ token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token")
22
+ token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification")
23
+ permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions")
24
+ expires_at = models.DateTimeField(null=True, blank=True)
25
+ last_used_at = models.DateTimeField(null=True, blank=True)
26
+
27
+ objects = ActiveManager()
28
+ all_objects = models.Manager()
29
+
30
+ class Meta:
31
+ ordering = ["-created_at"]
32
+
33
+ @staticmethod
34
+ def generate():
35
+ """Generate a new token. Returns (raw_token, token_hash, prefix)."""
36
+ raw = f"frp_{secrets.token_urlsafe(32)}"
37
+ hash_val = hashlib.sha256(raw.encode()).hexdigest()
38
+ prefix = raw[:12]
39
+ return raw, hash_val, prefix
40
+
41
+ @staticmethod
42
+ def hash_token(raw_token):
43
+ return hashlib.sha256(raw_token.encode()).hexdigest()
44
+
45
+ def has_permission(self, permission):
46
+ """Check if this token has a specific permission."""
47
+ perms = [p.strip() for p in self.permissions.split(",")]
48
+ return permission in perms or "*" in perms
49
+
50
+ def __str__(self):
51
+ return f"{self.name} ({self.token_prefix}...)"
52
+
53
+
54
+def authenticate_api_token(request, repository):
55
+ """Check Bearer token auth. Returns APIToken or None."""
56
+ auth = request.META.get("HTTP_AUTHORIZATION", "")
57
+ if not auth.startswith("Bearer "):
58
+ return None
59
+ raw_token = auth[7:]
60
+ token_hash = APIToken.hash_token(raw_token)
61
+ try:
62
+ token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
63
+ # Check expiry
64
+ if token.expires_at and token.expires_at < timezone.now():
65
+ return None
66
+ token.last_used_at = timezone.now()
67
+ token.save(update_fields=["last_used_at"])
68
+ return token
69
+ except APIToken.DoesNotExist:
70
+ return None
--- a/fossil/api_tokens.py
+++ b/fossil/api_tokens.py
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/api_tokens.py
+++ b/fossil/api_tokens.py
@@ -0,0 +1,70 @@
1 """API tokens scoped to a repository for CI/CD and automation.
2
3 Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation
4 and never stored in plaintext.
5 """
6
7 import hashlib
8 import secrets
9
10 from django.db import models
11 from django.utils import timezone
12
13 from core.models import ActiveManager, Tracking
14
15
16 class APIToken(Tracking):
17 """API token scoped to a repository for CI/CD and automation."""
18
19 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens")
20 name = models.CharField(max_length=200)
21 token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token")
22 token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification")
23 permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions")
24 expires_at = models.DateTimeField(null=True, blank=True)
25 last_used_at = models.DateTimeField(null=True, blank=True)
26
27 objects = ActiveManager()
28 all_objects = models.Manager()
29
30 class Meta:
31 ordering = ["-created_at"]
32
33 @staticmethod
34 def generate():
35 """Generate a new token. Returns (raw_token, token_hash, prefix)."""
36 raw = f"frp_{secrets.token_urlsafe(32)}"
37 hash_val = hashlib.sha256(raw.encode()).hexdigest()
38 prefix = raw[:12]
39 return raw, hash_val, prefix
40
41 @staticmethod
42 def hash_token(raw_token):
43 return hashlib.sha256(raw_token.encode()).hexdigest()
44
45 def has_permission(self, permission):
46 """Check if this token has a specific permission."""
47 perms = [p.strip() for p in self.permissions.split(",")]
48 return permission in perms or "*" in perms
49
50 def __str__(self):
51 return f"{self.name} ({self.token_prefix}...)"
52
53
54 def authenticate_api_token(request, repository):
55 """Check Bearer token auth. Returns APIToken or None."""
56 auth = request.META.get("HTTP_AUTHORIZATION", "")
57 if not auth.startswith("Bearer "):
58 return None
59 raw_token = auth[7:]
60 token_hash = APIToken.hash_token(raw_token)
61 try:
62 token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
63 # Check expiry
64 if token.expires_at and token.expires_at < timezone.now():
65 return None
66 token.last_used_at = timezone.now()
67 token.save(update_fields=["last_used_at"])
68 return token
69 except APIToken.DoesNotExist:
70 return None
--- a/fossil/api_views.py
+++ b/fossil/api_views.py
@@ -0,0 +1,1761 @@
1
+"""JSON API endpoints for programmatic access to Fossil repositories.
2
+
3
+All endpoints live under /projects/<slug>/fossil/api/.
4
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
5
+All responses are JSON. All read endpoints check can_read_project.
6
+"""
7
+
8
+import json
9
+import logging
10
+import math
11
+import re
12
+import shutil
13
+import subprocess
14
+import tempfile
15
+import time
16
+
17
+from django.db import transaction
18
+from django.http import JsonResponse, StreamingHttpResponse
19
+from django.shortcuts import get_object_or_404
20
+from django.test import RequestFactory
21
+from django.utils import timezone
22
+from django.views.decorators.csrf import csrf_exempt
23
+from django.views.decorators.http import require_GET
24
+
25
+from fossil.api_auth import authenticate_request
26
+from fossil.models import FossilRepository
27
+from fossil.reader import FossilReader
28
+from projects.access import can_ort can_admin_project, can_read_project, can_write_project
29
+from projects.models import Project
30
+
31
+logger = logging.getLogger(__name__)
32
+
33
+
34
+def _get_repo(slug):
35
+ """Look up project and repository by slug, or return 404 JSON."""
36
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
37
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
38
+ return project, repo
39
+
40
+
41
+def _check_api_api_auth(requeread acces "write" — enforced on both API tokens and PAT scopes.
42
+
43
+ Returns (user, token, error_response). If error_response is not None,
44
+ the caller should return it immediately.
45
+ """
46
+ user, tokepoints for pro"""JSON API endpoints for programmatic access to Fossil repositories.
47
+
48
+All endpointsalreadyuser), the token itself gr. StreamingHttpResy checked.
49
+ if token is not None and user is None:
50
+ return user, token, None
51
+
52
+ # For user-scoped auth (PAT or session), check project visibil andd"}, status=403)
53
+ and not can_writAccess denid.
54
+ if token is not Non.views.decorators.http import require_GET
55
+
56
+from fossil.api_auth import authenticate_request
57
+from fossil.models import FossilRepository
58
+from fossil.reader import FossilReader
59
+from projects.access import can_ort can_admin_project, can_read_project, can_write_project
60
+from projects.models import Project
61
+
62
+logger = logging.getLogger(__name__)
63
+
64
+
65
+def _get_repo(slug):
66
+ """Look up project and repository by slug, or return 404 JSON."""
67
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
68
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
69
+ return project, repo
70
+
71
+
72
+def _check_api_auth(request, project, repo, required_scope="read"):
73
+ """Authenticate request and check access.
74
+
75
+ Args:
76
+ required_scope: "read" or "write" — enforced on both API tokens and PAT scopes.
77
+
78
+ Returns (user, token, error_response). If error_response is not None,
79
+ the caller should return it immediately.
80
+ """
81
+ user, token, err = authenticate_request(request, repository=repo, required_scope=required_scope)
82
+ if err is not None:
83
+ return None, None, err
84
+
85
+ # For project-scoped APITokens (no user), the token itself grants access
86
+ # since it's scoped to this repository and scope was already checked.
87
+ if token is not None and user is None:
88
+ return user, token, None
89
+
90
+ # For user-scoped auth (PAT or session), check project visibility
91
+ if user is not None:
92
+ if required_scope == "write" and not can_write_project(user, project):
93
+ return None, None, JsonResponse({"error": "Write access required"}, status=403)
94
+ if not can_read_project(user, project):
95
+ return None, None, JsonResponse({"error": "Access denied"}, status=403)
96
+
97
+ return user, token, None
98
+
99
+
100
+def _paginate_params(request, default_per_page=25, max_per_page=100):
101
+ """Extract and validate page/per_page from query params."""
102
+ try:
103
+ page = max(1, int(request.GET.get("page", "1")))
104
+ except (ValueError, TypeError):
105
+ page = 1
106
+ try:
107
+ per_page = min(max_per_page, max(1, int(request.GET.get("per_page", str(default_per_page)))))
108
+ except (ValueError, TypeError):
109
+ per_page = default_per_page
110
+ return page, per_page
111
+
112
+
113
+def _isoformat(dt):
114
+ """Safely format a datetime to ISO 8601, or None."""
115
+ if dt is None:
116
+ return None
117
+ return dt.isoformat()
118
+
119
+
120
+# --- API Documentation ---
121
+
122
+
123
+@csrf_exempt
124
+@require_GET
125
+def api_docs(request, slug):
126
+ """Return JSON listing all available API endpoints with descriptions."""
127
+ base = f"/projects/{slug}/fossil/api"
128
+ return JsonResponse(
129
+ {
130
+ "endpoints": [
131
+ {"method": "GET", "path": f"{base}/project", "description": "Project metadata"},
132
+ {
133
+ "method": "GET",
134
+ "path": f"{base}/timeline",
135
+ "description": "Recent checkins (paginated)",
136
+ "params": "page, per_page, branch",
137
+ },
138
+ {
139
+ "method": "GET",
140
+ "path": f"{base}/tickets",
141
+ "description": "Ticket list (paginated, filterable)",
142
+ "params": "page, per_page, status",
143
+ },
144
+ {"method": "GET", "path": f"{base}/tickets/<uuid>", "description": "Single ticket detail with comments"},
145
+ {"method": "GET", "path": f"{base}/wiki", "description": "Wiki page list"},
146
+ {"method": "GET", "path": f"{base}/wiki/<name>", "description": "Single wiki page with content"},
147
+ {"method": "GET", "path": f"{base}/branches", "description": "Branch list"},
148
+ {"method": "GET", "path": f"{base}/tags", "description": "Tag list"},
149
+ {"method": "GET", "path": f"{base}/releases", "description": "Release list"},
150
+ {"method": "GET", "path": f"{base}/search", "description": "Search across checkins, tickets, wiki", "params": "q"},
151
+ {
152
+ "method": "POST",
153
+ "path": f"{base}/batch",
154
+ "description": "Execute multiple API calls in a single request (max 25)",
155
+ "body": '{"requests": [{"method": "GET", "path": "/api/timeline", "params": {}}]}',
156
+ },
157
+ {"method": "GET", "path": f"{base}/workspaces", "description": "List agent workspaces", "params": "status"},
158
+ {
159
+ "method": "POST",
160
+ "path": f"{base}/workspaces/create",
161
+ "description": "Create an isolated agent workspace",
162
+ "body": '{"name": "...", "description": "...", "agent_id": "..."}',
163
+ },
164
+ {"method": "GET", "path": f"{base}/workspaces/<name>", "description": "Get workspace details"},
165
+ {
166
+ "method": "POST",
167
+ "path": f"{base}/workspaces/<name>/commit",
168
+ "description": "Commit changes in a workspace",
169
+ "body": '{"message": "...", "files": []}',
170
+ },
171
+ {
172
+ "method": "POST",
173
+ "path": f"{base}/workspaces/<name>/merge",
174
+ "description": "Merge workspace branch back to trunk",
175
+ "body": '{"target_branch": "trunk"}',
176
+ },
177
+ {
178
+ "method": "DELETE",
179
+ "path": f"{base}/workspaces/<name>/abandon",
180
+ "description": "Abandon and clean up a workspace",
181
+ },
182
+ {
183
+ "method": "POST",
184
+ "path": f"{base}/tickets/<uuid>/claim",
185
+ "description": "Claim a ticket for exclusive agent work",
186
+ "body": '{"agent_id": "...", "workspace": "..."}',
187
+ },
188
+ {
189
+ "method": "POST",
190
+ "path": f"{base}/tickets/<uuid>/release",
191
+ "description": "Release a ticket claim",
192
+ },
193
+ {
194
+ "method": "POST",
195
+ "path": f"{base}/tickets/<uuid>/submit",
196
+ "description": "Submit completed work for a claimed ticket",
197
+ "body": '{"summary": "...", "files_changed": [...]}',
198
+ },
199
+ {
200
+ "method": "GET",
201
+ "path": f"{base}/tickets/unclaimed",
202
+ "description": "List tickets not claimed by any agent",
203
+ "params": "status, limit",
204
+ },
205
+ {"method": "GET", "path": f"{base}/events", "description": "Server-Sent Events stream for real-time events"},
206
+ {
207
+ "method": "POST",
208
+ "path": f"{base}/reviews/create",
209
+ "description": "Submit code changes for review",
210
+ "body": '{"title": "...", "diff": "...", "files_changed": [...], "agent_id": "..."}',
211
+ },
212
+ {
213
+ "method": "GET",
214
+ "path": f"{base}/reviews",
215
+ "description": "List code reviews",
216
+ "params": "status, page, per_page",
217
+ },
218
+ {"method": "GET", "path": f"{base}/reviews/<id>", "description": "Get review with comments"},
219
+ {
220
+ "method": "POST",
221
+ "path": f"{base}/reviews/<id>/comment",
222
+ "description": "Add a comment to a review",
223
+ "body": '{"body": "...", "file_path": "...", "line_number": 42, "author": "..."}',
224
+ },
225
+ {"method": "POST", "path": f"{base}/reviews/<id>/approve", "description": "Approve a review"},
226
+ {"method": "POST", "path": f"{base}/reviews/<id>/request-changes", "description": "Request changes on a review"},
227
+ {"method": "POST", "path": f"{base}/reviews/<id>/merge", "description": "Merge an approved review"},
228
+ ],
229
+ "auth": "Bearer token (Authorization: Bearer <token>) or session cookie",
230
+ }
231
+ )
232
+
233
+
234
+# --- Project Metadata ---
235
+
236
+
237
+@csrf_exempt
238
+@require_GET
239
+def api_project(request, slug):
240
+ """Return project metadata as JSON."""
241
+ project, repo = _get_repo(slug)
242
+ user, token, err = _check_api_auth(request, project, repo)
243
+ if err is not None:
244
+ return err
245
+
246
+ return JsonResponse(
247
+ {
248
+ "name": project.name,
249
+ "slug": project.slug,
250
+ "description": project.description or "",
251
+ "visibility": project.visibility,
252
+ "star_count": project.star_count,
253
+ }
254
+ )
255
+
256
+
257
+# --- Timeline ---
258
+
259
+
260
+@csrf_exempt
261
+@require_GET
262
+def api_timeline(request, slug):
263
+ """Return recent checkins as JSON, paginated."""
264
+ project, repo = _get_repo(slug)
265
+ user, token, err = _check_api_auth(request, project, repo)
266
+ if err is not None:
267
+ return err
268
+
269
+ page, per_page = _paginate_params(request)
270
+ branch_filter = request.GET.get("branch", "").strip()
271
+ offset = (page - 1) * per_page
272
+
273
+ reader = FossilReader(repo.full_path)
274
+ with reader:
275
+ entries = reader.get_timeline(limit=per_page, offset=offset, event_type="ci")
276
+ total = reader.get_checkin_count()
277
+
278
+ checkins = []
279
+ for e in entries:
280
+ entry = {
281
+ "uuid": e.uuid,
282
+ "timestamp": _isoformat(e.timestamp),
283
+ "user": e.user,
284
+ "comment": e.comment,
285
+ "branch": e.branch,
286
+ }
287
+ checkins.append(entry)
288
+
289
+ # If branch filter is set, filter in Python (Fossil's timeline query
290
+ # doesn't support branch filtering at the SQL level without extra joins).
291
+ if branch_filter:
292
+ checkins = [c for c in checkins if c["branch"] == branch_filter]
293
+
294
+ total_pages = max(1, math.ceil(total / per_page))
295
+
296
+ return JsonResponse(
297
+ {
298
+ "checkins": checkins,
299
+ "total": total,
300
+ "page": page,
301
+ "per_page": per_page,
302
+ "total_pages": total_pages,
303
+ }
304
+ )
305
+
306
+
307
+# --- Tickets ---
308
+
309
+
310
+@csrf_exempt
311
+@require_GET
312
+def api_tickets(request, slug):
313
+ """Return ticket list as JSON, paginated and filterable by status."""
314
+ project, repo = _get_repo(slug)
315
+ user, token, err = _check_api_auth(request, project, repo)
316
+ if err is not None:
317
+ return err
318
+
319
+ page, per_page = _paginate_params(request)
320
+ status_filter = request.GET.get("status", "").strip() or None
321
+
322
+ reader = FossilReader(repo.full_path)
323
+ with reader:
324
+ all_tickets = reader.get_tickets(status=status_filter, limit=1000)
325
+
326
+ total = len(all_tickets)
327
+ total_pages = max(1, math.ceil(total / per_page))
328
+ page = min(page, total_pages)
329
+ page_tickets = all_tickets[(page - 1) * per_page : page * per_page]
330
+
331
+ tickets = []
332
+ for t in page_tickets:
333
+ tickets.append(
334
+ {
335
+ "uuid": t.uuid,
336
+ "title": t.title,
337
+ "status": t.status,
338
+ "type": t.type,
339
+ "subsystem": t.subsystem,
340
+ "priority": t.priority,
341
+ "severity": t.severity,
342
+ "created": _isoformat(t.created),
343
+ }
344
+ )
345
+
346
+ return JsonResponse(
347
+ {
348
+ "tickets": tickets,
349
+ "total": total,
350
+ "page": page,
351
+ "per_page": per_page,
352
+ "total_pages": total_pages,
353
+ }
354
+ )
355
+
356
+
357
+@csrf_exempt
358
+@require_GET
359
+def api_ticket_detail(request, slug, ticket_uuid):
360
+ """Return a single ticket with its comments as JSON."""
361
+ project, repo = _get_repo(slug)
362
+ user, token, err = _check_api_auth(request, project, repo)
363
+ if err is not None:
364
+ return err
365
+
366
+ reader = FossilReader(repo.full_path)
367
+ with reader:
368
+ ticket = reader.get_ticket_detail(ticket_uuid)
369
+ if ticket is None:
370
+ return JsonResponse({"error": "Ticket not found"}, status=404)
371
+ comments = reader.get_ticket_comments(ticket_uuid)
372
+
373
+ comment_list = []
374
+ for c in comments:
375
+ comment_list.append(
376
+ {
377
+ "timestamp": _isoformat(c.get("timestamp")),
378
+ "user": c.get("user", ""),
379
+ "comment": c.get("comment", ""),
380
+ "mimetype": c.get("mimetype", "text/plain"),
381
+ }
382
+ )
383
+
384
+ return JsonResponse(
385
+ {
386
+ "uuid": ticket.uuid,
387
+ "title": ticket.title,
388
+ "status": ticket.status,
389
+ "type": ticket.type,
390
+ "subsystem": ticket.subsystem,
391
+ "priority": ticket.priority,
392
+ "severity": ticket.severity,
393
+ "resolution": ticket.resolution,
394
+ "body": ticket.body,
395
+ "created": _isoformat(ticket.created),
396
+ "comments": comment_list,
397
+ }
398
+ )
399
+
400
+
401
+# --- Wiki ---
402
+
403
+
404
+@csrf_exempt
405
+@require_GET
406
+def api_wiki_list(request, slug):
407
+ """Return list of wiki pages as JSON."""
408
+ project, repo = _get_repo(slug)
409
+ user, token, err = _check_api_auth(request, project, repo)
410
+ if err is not None:
411
+ return err
412
+
413
+ reader = FossilReader(repo.full_path)
414
+ with reader:
415
+ pages = reader.get_wiki_pages()
416
+
417
+ page_list = []
418
+ for p in pages:
419
+ page_list.append(
420
+ {
421
+ "name": p.name,
422
+ "last_modified": _isoformat(p.last_modified),
423
+ "user": p.user,
424
+ }
425
+ )
426
+
427
+ return JsonResponse({"pages": page_list})
428
+
429
+
430
+@csrf_exempt
431
+@require_GET
432
+def api_wiki_page(request, slug, page_name):
433
+ """Return a single wiki page with its content as JSON."""
434
+ project, repo = _get_repo(slug)
435
+ user, token, err = _check_api_auth(request, project, repo)
436
+ if err is not None:
437
+ return err
438
+
439
+ reader = FossilReader(repo.full_path)
440
+ with reader:
441
+ page = reader.get_wiki_page(page_name)
442
+
443
+ if page is None:
444
+ return JsonResponse({"error": "Wiki page not found"}, status=404)
445
+
446
+ # Render content to HTML for convenience
447
+ from fossil.views import _render_fossil_content
448
+
449
+ content_html = _render_fossil_content(page.content, project_slug=slug) if page.content else ""
450
+
451
+ return JsonResponse(
452
+ {
453
+ "name": page.name,
454
+ "content": page.content,
455
+ "content_html": content_html,
456
+ "last_modified": _isoformat(page.last_modified),
457
+ "user": page.user,
458
+ }
459
+ )
460
+
461
+
462
+# --- Branches ---
463
+
464
+
465
+@csrf_exempt
466
+@require_GET
467
+def api_branches(request, slug):
468
+ """Return list of branches as JSON."""
469
+ project, repo = _get_repo(slug)
470
+ user, token, err = _check_api_auth(request, project, repo)
471
+ if err is not None:
472
+ return err
473
+
474
+ reader = FossilReader(repo.full_path)
475
+ with reader:
476
+ branches = reader.get_branches()
477
+
478
+ branch_list = []
479
+ for b in branches:
480
+ branch_list.append(
481
+ {
482
+ "name": b["name"],
483
+ "last_checkin": _isoformat(b["last_checkin"]),
484
+ "last_user": b["last_user"],
485
+ "checkin_count": b["checkin_count"],
486
+ "last_uuid": b["last_uuid"],
487
+ }
488
+ )
489
+
490
+ return JsonResponse({"branches": branch_list})
491
+
492
+
493
+# --- Tags ---
494
+
495
+
496
+@csrf_exempt
497
+@require_GET
498
+def api_tags(request, slug):
499
+ """Return list of tags as JSON."""
500
+ project, repo = _get_repo(slug)
501
+ user, token, err = _check_api_auth(request, project, repo)
502
+ if err is not None:
503
+ return err
504
+
505
+ reader = FossilReader(repo.full_path)
506
+ with reader:
507
+ tags = reader.get_tags()
508
+
509
+ tag_list = []
510
+ for t in tags:
511
+ tag_list.append(
512
+ {
513
+ "name": t["name"],
514
+ "timestamp": _isoformat(t["timestamp"]),
515
+ "user": t["user"],
516
+ "uuid": t["uuid"],
517
+ }
518
+ )
519
+
520
+ return JsonResponse({"tags": tag_list})
521
+
522
+
523
+# --- Releases ---
524
+
525
+
526
+@csrf_exempt
527
+@require_GET
528
+def api_releases(request, slug):
529
+ """Return list of releases as JSON. Drafts excluded for non-writers."""
530
+ project, repo = _get_repo(slug)
531
+ user, token, err = _check_api_auth(request, project, repo)
532
+ if err is not None:
533
+ return err
534
+
535
+ from fossil.releases import Release
536
+ from projects.access import can_write_project
537
+
538
+ releases_qs = Release.objects.filter(repository=repo, deleted_at__isnull=True)
539
+
540
+ # Only show drafts to users with write access
541
+ has_write = False
542
+ if user is not None:
543
+ has_write = can_write_project(user, project)
544
+ if not has_write:
545
+ releases_qs = releases_qs.filter(is_draft=False)
546
+
547
+ release_list = []
548
+ for r in releases_qs:
549
+ assets = []
550
+ for a in r.assets.filter(deleted_at__isnull=True):
551
+ assets.append(
552
+ {
553
+ "name": a.name,
554
+ "file_size_bytes": a.file_size_bytes,
555
+ "content_type": a.content_type,
556
+ "download_count": a.download_count,
557
+ }
558
+ )
559
+ release_list.append(
560
+ {
561
+ "tag_name": r.tag_name,
562
+ "name": r.name,
563
+ "body": r.body,
564
+ "is_prerelease": r.is_prerelease,
565
+ "is_draft": r.is_draft,
566
+ "published_at": _isoformat(r.published_at),
567
+ "checkin_uuid": r.checkin_uuid,
568
+ "assets": assets,
569
+ }
570
+ )
571
+
572
+ return JsonResponse({"releases": release_list})
573
+
574
+
575
+# --- Search ---
576
+
577
+
578
+@csrf_exempt
579
+@require_GET
580
+def api_search(request, slug):
581
+ """Search across checkins, tickets, and wiki pages. Returns JSON."""
582
+ project, repo = _get_repo(slug)
583
+ user, token, err = _check_api_auth(request, project, repo)
584
+ if err is not None:
585
+ return err
586
+
587
+ query = request.GET.get("q", "").strip()
588
+ if not query:
589
+ return JsonResponse({"error": "Query parameter 'q' is required"}, status=400)
590
+
591
+ reader = FossilReader(repo.full_path)
592
+ with reader:
593
+ results = reader.search(query, limit=50)
594
+
595
+ # Serialize datetimes in results
596
+ for checkin in results.get("checkins", []):
597
+ checkin["timestamp"] = _isoformat(checkin.get("timestamp"))
598
+ for ticket in results.get("tickets", []):
599
+ ticket["created"] = _isoformat(ticket.get("created"))
600
+
601
+ return JsonResponse(results)
602
+
603
+
604
+# --- Batch API ---
605
+
606
+# Map API paths to (view_function, extra_path_regex_or_None).
607
+# Entries with a regex capture group extract path params (e.g. ticket uuid, wiki page name).
608
+_BATCH_STATIC_ROUTES = {
609
+ "/api/project": api_project,
610
+ "/api/timeline": api_timeline,
611
+ "/api/tickets": api_tickets,
612
+ "/api/wiki": api_wiki_list,
613
+ "/api/branches": api_branches,
614
+ "/api/tags": api_tags,
615
+ "/api/releases": api_releases,
616
+ "/api/search": api_search,
617
+}
618
+
619
+_BATCH_DYNAMIC_ROUTES = [
620
+ (re.compile(r"^/api/tickets/([0-9a-fA-F-]+)$"), api_ticket_detail, "ticket_uuid"),
621
+ (re.compile(r"^/api/wiki/(.+)$"), api_wiki_page, "page_name"),
622
+]
623
+
624
+_BATCH_MAX_REQUESTS = 25
625
+
626
+
627
+def _resolve_batch_route(path):
628
+ """Resolve a batch sub-request path to (view_func, kwargs) or (None, None)."""
629
+ view_func = _BATCH_STATIC_ROUTES.get(path)
630
+ if view_func is not None:
631
+ return view_func, {}
632
+
633
+ for pattern, view_func, kwarg_name in _BATCH_DYNAMIC_ROUTES:
634
+ m = pattern.match(path)
635
+ if m:
636
+ return view_func, {kwarg_name: m.group(1)}
637
+
638
+ return None, None
639
+
640
+
641
+@csrf_exempt
642
+def api_batch(request, slug):
643
+ """Execute multiple API calls in a single request.
644
+
645
+ POST /projects/<slug>/fossil/api/batch
646
+ {
647
+ "requests": [
648
+ {"method": "GET", "path": "/api/timeline", "params": {"per_page": 5}},
649
+ {"method": "GET", "path": "/api/tickets", "params": {"status": "Open"}},
650
+ {"method": "GET", "path": "/api/wiki/Home"}
651
+ ]
652
+ }
653
+
654
+ Returns:
655
+ {
656
+ "responses": [
657
+ {"status": 200, "body": {...}},
658
+ {"status": 200, "body": {...}},
659
+ {"status": 200, "body": {...}}
660
+ ]
661
+ }
662
+
663
+ Auth: same as other API endpoints (Bearer token or session).
664
+ Limit: 25 sub-requests per batch.
665
+ Only GET sub-requests are supported.
666
+ """
667
+ if request.method != "POST":
668
+ return JsonResponse({"error": "POST required"}, status=405)
669
+
670
+ # Auth check -- same as every other API endpoint
671
+ project, repo = _get_repo(slug)
672
+ user, token, err = _check_api_auth(request, project, repo)
673
+ if err is not None:
674
+ return err
675
+
676
+ try:
677
+ body = json.loads(request.body)
678
+ except (json.JSONDecodeError, ValueError):
679
+ return JsonResponse({"error": "Invalid JSON body"}, status=400)
680
+
681
+ requests_list = body.get("requests")
682
+ if not isinstance(requests_list, list):
683
+ return JsonResponse({"error": "'requests' must be a list"}, status=400)
684
+
685
+ if len(requests_list) > _BATCH_MAX_REQUESTS:
686
+ return JsonResponse({"error": f"Maximum {_BATCH_MAX_REQUESTS} requests per batch"}, status=400)
687
+
688
+ if len(requests_list) == 0:
689
+ return JsonResponse({"responses": []})
690
+
691
+ factory = RequestFactory()
692
+ responses = []
693
+
694
+ for sub in requests_list:
695
+ if not isinstance(sub, dict):
696
+ responses.append({"status": 400, "body": {"error": "Each request must be an object"}})
697
+ continue
698
+
699
+ method = (sub.get("method") or "GET").upper()
700
+ path = sub.get("path", "")
701
+ params = sub.get("params") or {}
702
+
703
+ if method != "GET":
704
+ responses.append({"status": 405, "body": {"error": "Only GET is supported in batch requests"}})
705
+ continue
706
+
707
+ if not path:
708
+ responses.appendpoints for programmatic"""JSON API endpoints for programmatic access to Fossil repositories.
709
+
710
+All endpoints live under /projects/<slug>/fossil/api/.
711
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
712
+All responses are JSON. All read endpoints check can_read_project.
713
+"""
714
+
715
+import json
716
+import logging
717
+import math
718
+import re
719
+import shutil
720
+import subprocess
721
+import tempfile
722
+import time
723
+
724
+from django.db import transaction
725
+from django.http import JsonResponse, StreamingHttpResponse
726
+from django.shortcuts import get_object_or_404
727
+from django.test import RequestFactory
728
+from django.utils import timezone
729
+from django.views.decorators.csrf import csrf_exempt
730
+from django.views.decorators.http import require_GET
731
+
732
+from fossil.api_auth import authenticate_request
733
+from fossil.models import FossilRepository
734
+from fossil.reader import FossilReader
735
+from projects.access import can_ort can_admin_project, can_read_project, can_write_project
736
+from projects.models import Project
737
+
738
+logger = logging.getLogger(__name__)
739
+
740
+
741
+def _get_repo(slug):
742
+ """Look up project and repository by slug, or return 404 JSON."""
743
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
744
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
745
+ return project, repo
746
+
747
+
748
+def _check_api_auth(request, project, repo, required_scope="read"):
749
+ """Authenticate request and check access.
750
+
751
+ Args:
752
+ required_scope: "read" or "write" — enforced on both API tokens and PAT scopes.
753
+
754
+ Returns (user, token, error_response). If error_response is not None,
755
+ the caller should return it immediately.
756
+ """
757
+ user, token, err = authenticate_request(request, repository=repo, required_scope=required_scope)
758
+ if err is not None:
759
+ return None, None, err
760
+
761
+ # For project-scoped APITokens (no user), the token itself grants access
762
+ # since it's scoped to this repository and scope was already checked.
763
+ if token is not None and user is None:
764
+ return user, token, None
765
+
766
+ # For user-scoped auth (PAT or session), check project visibility
767
+ if user is not None:
768
+ if required_scope == "write" and not can_write_project(user, project):
769
+ return None, None, JsonResponse({"error": "Write access required"}, status=403)
770
+ if not can_read_project(user, project):
771
+ return None, None, JsonResponse({"error": "Access denied"}, status=403)
772
+
773
+ return user, token, None
774
+
775
+
776
+def _paginate_params(request, default_per_page=25, max_per_page=100):
777
+ """Extract and validate page/per_page from query params."""
778
+ try:
779
+ page = max(1, int(request.GET.get("page", "1")))
780
+ except (ValueError, TypeError):
781
+ page = 1
782
+ try:
783
+ per_page = min(max_per_page, max(1, int(request.GET.get("per_page", str(default_per_page)))))
784
+ except (ValueError, TypeError):
785
+ per_page = default_per_page
786
+ return page, per_page
787
+
788
+
789
+def _isoformat(dt):
790
+ """Safely format a datetime to ISO 8601, or None."""
791
+ if dt is None:
792
+ return None
793
+ return dt.isoformat()
794
+
795
+
796
+# --- API Documentation ---
797
+
798
+
799
+@csrf_exempt
800
+@require_GET
801
+def api_docs(request, slug):
802
+ """Return JSON listing all available API endpoints with descriptions."""
803
+ base = f"/projects/{slug}/fossil/api"
804
+ return JsonResponse(
805
+ {
806
+ "endpoints": [
807
+ {"method": "GET", "path": f"{base}/project", "description": "Project metadata"},
808
+ {
809
+ "method": "GET",
810
+ "path": f"{base}/timeline",
811
+ "description": "Recent checkins (paginated)",
812
+ "params": "page, per_page, branch",
813
+ },
814
+ {
815
+ "method": "GET",
816
+ "path": f"{base}/tickets",
817
+ "description": "Ticket list (paginated, filterable)",
818
+ "params": "page, per_page, status",
819
+ },
820
+ {"method": "GET", "path": f"{base}/tickets/<uuid>", "description": "Single ticket detail with comments"},
821
+ {"method": "GET", "path": f"{base}/wiki", "description": "Wiki page list"},
822
+ {"method": "GET", "path": f"{base}/wiki/<name>", "description": "Single wiki page with content"},
823
+ {"method": "GET", "path": f"{base}/branches", "description": "Branch list"},
824
+ {"method": "GET", "path": f"{base}/tags", "description": "Tag list"},
825
+ {"method": "GET", "path": f"{base}/releases", "description": "Release list"},
826
+ {"method": "GET", "path": f"{base}/search", "description": "Search across checkins, tickets, wiki", "params": "q"},
827
+ {
828
+ "method": "POST",
829
+ "path": f"{base}/batch",
830
+ "description": "Execute multiple API calls in a single request (max 25)",
831
+ "body": '{"requests": [{"method": "GET", "path": "/api/timeline", "params": {}}]}',
832
+ },
833
+ {"method": "GET", "path": f"{base}/workspaces", "description": "List agent workspaces", "params": "status"}points for programmatic access to Fossil repositories.
834
+
835
+All endpoints live under /projects/<slug>/fossil/api/.
836
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
837
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
838
+
839
+All endpoints live under /projects/<slug>/fossil/api/.
840
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
841
+All responses a{ess required"}, ion
842
+
843
+ for protection in BranchProtection.objects.filter(repository=repo, deleted_at__isnull=True):
844
+ if protection.matches_branch(target_branch):
845
+ if protection.restrict_push:
846
+ return JsonResponse(
847
+ {"error": f"Branch '{target_branch}' is protected: only admins can merge to it"},
848
+ status=403,
849
+ )
850
+ if protection.require_status_checks:
851
+ from fossil.ci import StatusCheck
852
+
853
+ for context in protection.get_required_contexts_list():
854
+ latest = StatusCheck.objects.filter(repository=repo, context=context).order_by("-created_at").first()
855
+ if not latest or latest.state != "success":
856
+ return JsonResponse(
857
+ points for programmatic access to Fossil repositories.
858
+
859
+All endpoints live under /projects/<slug>/fossil/api/.
860
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
861
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
862
+
863
+All endpoints live under /projects/<slug>/fossil/api/.
864
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
865
+All responses a{ess required"}, ody)
866
+ except (json.JSONDecodeError, ValueError):
867
+ return JsonResponse({"error": "Invalid JSON body"}, status=400)
868
+
869
+ agent_id = (data.get("agent_id") or "").strip()
870
+ if not agent_id:
871
+ return JsonResponse({"error": "agent_id is required"}, stm.ticket_uuid,
872
+ "a01,
873
+ )
874
+
875
+
876
+@csrf_exempt
877
+def api_ticket_release(request, slug, ticket_uuid):
878
+ """Release a ticke },
879
+ status=201,
880
+ )
881
+
882
+
883
+@csrf_exempt
884
+def api_ticket_release(request, slug, ticket_uuid):
885
+ """Release a ticket claim.
886
+
887
+ POST /projects/<slug>/fossil/api/tickets/<uuid>/release
888
+ {"agent_id": "claude-abc"}
889
+
890
+ Soft-deletes the claim record so the unique constraint slot is freed.
891
+ """
892
+ if request.method != "POST":
893
+ return JsonResponse({"error": "POST required"}, status=405)
894
+
895
+ project, repo = _get_repo(slug)
896
+ user, token, err = _check_api_auth(request, project, repo, required_scope="write")
897
+ if err is not None:
898
+ return err
899
+
900
+ if token is None and (user is None or not can_write_project(user, project)):
901
+ return JsonResponse({"error": "Write access required"}, status=403)
902
+
903
+ try:
904
+ data = json.loads(request.body) if request.body else {}
905
+ except (json.JSONDecodeError, ValueError):
906
+ data = {}
907
+
908
+ agent_id = (data.get("agent_id") or "").strip()
909
+ if not agent_id:
910
+ return JsonResponse({"error": "agent_id is required"}, status=400)
911
+
912
+ # Closee bug by ...",
913
+ up
914
+lt.returncode != 0:
915
+ # Clean up on failure
916
+ subprocess.run([cli.binary, "close", = (data.get("agent_id") or "").strip()
917
+ if not agent_id:
918
+ return JsonResponse({"error": "agent_id is required"}, stm.ticket_uuid,
919
+ "a01,
920
+ )
921
+
922
+
923
+@csrf_exempt
924
+def api_ticket_release(request, slug, ticket_uuid):
925
+ """Release a ticke },
926
+ status=201,
927
+ )
928
+
929
+
930
+@csrf_exempt
931
+def api_ticket_release(request, slug, ticket_uuid):
932
+ """Release a ticket claim.
933
+
934
+ POST /projects/<slug>/fossil/api/tickets/<uuid>/release
935
+ {"agent_id": "claude-abc"}
936
+
937
+ Soft-deletes the claim record so the unique constraint slot is freed.
938
+ """
939
+ if request.method != "POST":
940
+ return JsonResponse({"error": "POST required"}, status=405)
941
+
942
+ project, repo = _get_repo(slug)
943
+ user, token, err = _check_api_auth(request, project, repo, required_scope="write")
944
+ if err is not None:
945
+ return err
946
+
947
+ if token is None and (user is None or not can_write_project(user, project)):
948
+ return JsonResponse({"error": "Write access required"}, status=403)
949
+
950
+ try:
951
+ data = json.loads(request.bodypoints for programmatic access to Fossil repositories.
952
+
953
+All endpoints live under /projects/<slug>/fossil/api/.
954
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
955
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
956
+
957
+All endpoints live under /projects/<slug>/fossil/api/.
958
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
959
+All responses are JSON. All read endpoints check can_read_project.
960
+"""
961
+
962
+import json
963
+import logging
964
+import math
965
+import re
966
+import shutil
967
+import subprocess
968
+import tempfile
969
+import time
970
+
971
+from django.db import transaction
972
+from django.http import JsonResponse, StreamingHttpResponse
973
+from django.shortcuts import get_object_or_404
974
+from django.test import RequestFactory
975
+from django.utils import timezone
976
+from django.views.decorators.csrf import csrf_exempt
977
+from django.views.decorators.http import require_GET
978
+
979
+from fossil.api_auth import authenticate_request
980
+from fossil.models import FossilRepository
981
+from fossil.reader import FossilReader
982
+from projects.access import can_ort can_admin_project, can_read_project, can_write_project
983
+from projects.models import Project
984
+
985
+logger = logging.getLogger(__name__)
986
+
987
+
988
+def _get_repo(slug):
989
+ """Look up project and repository by slug, or return 404 JSON."""
990
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
991
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
992
+ return project, repo
993
+
994
+
995
+def _check_api_auth(request, project, repo, required_scope="read"):
996
+ """Authenticate request and check access.
997
+
998
+ Args:
999
+ required_scope: "read" or "write" — enforced on both API tokens and PAT scopes.
1000
+
1001
+ Returns (user, token, error_response). If erropoints for programmatic access to Fossil repositories.
1002
+
1003
+All endpoints live under /projects/<slug>/fossil/api/.
1004
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1005
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
1006
+
1007
+All endpoints live under /projects/<slug>/fossil/api/.
1008
+Auth: Bearer tokf not agent_id:
1009
+ return JsonResponse({"error": "agent_id is required"}, status=400)
1010
+
1011
+ from fossil.agent_claims import TicketClaim
1012
+
1013
+ claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1014
+ if claim is None:
1015
+ return JsonResponse({"error": "No active claim for this ticket"}, status=404)
1016
+
1017
+ if claim.agent_id != agent_id:
1018
+ return JsonResponse({"error": "Only the claiming agent can submit work for this ticket"}, status=403)
1019
+
1020
+ if claim.status != "claimed":
1021
+ return JsonResponse({"error": f"Claim is already {claim.status}"}, status=409)
1022
+
1023
+ summary = (data.get("summary") or "").strip()
1024
+ files_changed = data.get("files_changed") or []
1025
+
1026
+ claim.status = "submitted"
1027
+ claim.summary = summary
1028
+ claim.files_changed = files_changed
1029
+ claim.save(update_fields=["status", "summary", "files_changed", "updated_at", "version"])
1030
+
1031
+ # Optionally add a comment to the Fossil ticket via CLI
1032
+ if summary:
1033
+ from fossil.cli import FossilCLI
1034
+
1035
+ cli = FossilCLI()
1036
+ points for programmatic access to Fossil repositories.
1037
+
1038
+All endpoints live under /projects/<slug>/fossil/api/.
1039
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1040
+All responses are JSON. All read endp"""JSON AP "branch": entry.branch,
1041
+ "timestamp": _isoformat(entry.timestamp),
1042
+ },
1043
+ ": _isoformat(entry.timestamp),
1044
+ },
1045
+ }
1046
+ )
1047
+ last_checkin_count = current_count
1048
+ except Exception:
1049
+ pass
1050
+
1051
+ # Check for new claims
1052
+ new_claims = TicketClaim.all_objects.filter(repository=repo, pk__gt=last_claim_id).order_by("pk")
1053
+ for claim in new_claims:
1054
+ pass
1055
+
1056
+ # Yield events
1057
+ for event in events:
1058
+ yield f"event: {event['type']}\ndata: {json.dumps(event['data'])}\n\n"
1059
+
1060
+ # Heartbeat every ~15 seconds (3 iterations * 5s sleep)
1061
+ heartbeat_counter += 1
1062
+ if not events and heartbeat_counter >= 3:
1063
+ yield ": heartbeat\n\n"
1064
+ heartbeat_counter = 0
1065
+
1066
+ time.sleep(5)
1067
+
1068
+ response = StreamingHttpResponse(event_stream(), content_type="text/event-stream")
1069
+ response["Cache-Control"] = "no-cache"
1070
+ response["X-Accel-Buffering"] = "no"
1071
+ return response
1072
+
1073
+
1074
+# --- Code Review API ---
1075
+
1076
+
1077
+@csrf_exempt
1078
+def api_review_create(request, slug):
1079
+ """Submit code changes for review.
1080
+
1081
+ points for programmatic access to Fossil repositories.
1082
+
1083
+All endpoints live under /projects/<slug>/fossil/api/.
1084
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1085
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
1086
+
1087
+All endpoints live under /projects/<slug>/fossil/api/.
1088
+Auth: Bearer toky\\n+++ b/src/auth.py\\ "comment": entry.comment,
1089
+ "branch": entry.branch,
1090
+ "timestamp": _isoformat(entry.timestamp),
1091
+ },
1092
+ }
1093
+ )
1094
+ last_checkin_count = current_count
1095
+ except Exception:
1096
+ pass
1097
+
1098
+ # Check for new claims
1099
+ new_claims = TicketClaim.all_objects.filter(repository=repo, pk__gt=last_claim_id).order_by("pk")
1100
+ for claim in new_claims:
1101
+ pass
1102
+
1103
+ # Yield events
1104
+ for event in events:
1105
+ yield f"event: {event['type']}\ndata: {json.dumps(event['data'])}\n\n"
1106
+
1107
+ # Heartbeat every ~15 seconds (3 iterations * 5s sleep)
1108
+ heartbeat_counter += 1
1109
+ if not events and heartbeat_counter >= 3:
1110
+ yield ": heartbeat\n\n"
1111
+ heartbeat_counter = 0
1112
+
1113
+ time.sleep(5)
1114
+
1115
+ response = StreamingHttpResponse(event_stream(), content_type="text/event-stream")
1116
+ response["Cache-Control"] = "no-cache"
1117
+ response["X-Accel-Buffering"] = "no"
1118
+ return response
1119
+
1120
+
1121
+# --- Code Review API ---
1122
+
1123
+
1124
+@csrf_exempt
1125
+def api_review_create(request, slug):
1126
+ """Submit code changes for review.
1127
+
1128
+ POST /projects/<slug>/fossil/api/reviews/create
1129
+ {
1130
+ "title": "Fix null pointer in auth module",
1131
+ "description": "The auth check was failing when ...",
1132
+ "diff": "--- a/src/auth.py\\n+++ b/src/auth.py\\n...",
1133
+ "files_changed": ["src/auth.py", "tests/test_auth.py"],
1134
+ "agent_id": "claude-abc",
1135
+ "workspace": "agent-fix-123",
1136
+ "ticket_uuid": "abc123..."
1137
+ }
1138
+ """
1139
+ if request.method != "POST":
1140
+ return JsonResponse({"error": "POST required"}, status=405)
1141
+
1142
+ project, repo = _get_repo(slug)
1143
+ user, token, err = _check_api_auth(request, project, repo, required_scope="write")
1144
+ if err is not None:
1145
+ return err
1146
+
1147
+ if token is None and (user is None or not can_write_project(user, project)):
1148
+ return JsonResponse({"error": "Write access required"}, status=403)
1149
+
1150
+ try:
1151
+ data = json.loads(request.body)
1152
+ except (json.JSONDecodeError, ValueError):
1153
+ return JsonResponse({"error": "Invalid JSON body"}, status=400)
1154
+
1155
+ title = (data.get("title") or "").strip()
1156
+ if not title:
1157
+ return JsonResponse({"error": "Review title is required"}, status=400)
1158
+
1159
+ diff = (data.get("diff") or "").strip()
1160
+ if not diff:
1161
+ return JsonResponse({"error": "Diff is required"}, status=400)
1162
+
1163
+ # Resolve optional workspace reference
1164
+ workspace_name = (data.get("workspace") or "").strip()
1165
+ workspace_obj = None
1166
+ if workspace_name:
1167
+ from fossil.workspaces import AgentWorkspace
1168
+
1169
+ workspace_obj = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
1170
+
1171
+ # If linking to a ticket, verify the caller owns the claim
1172
+ ticket_uuid = (data.get("ticket_uuid") or "").strip()
1173
+ review_agent_id = (data.get("agent_id") or "").strip()
1174
+ if ticket_uuid:
1175
+ from fossil.agent_claims import TicketClaim
1176
+
1177
+ claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1178
+ if claim is not None and review_agent_id and claim.agent_id != review_agent_id:
1179
+ return JsonResponse({"error": "Cannot create a review for a ticket claimed by another agent"}, status=403)
1180
+
1181
+ from fossil.code_reviews import CodeReview
1182
+
1183
+ review = CodeReview.objects.create(
1184
+ repository=repo,
1185
+ workspace=workspace_obj,
1186
+ title=title,
1187
+ description=data.get("description", ""),
1188
+ diff=diff,
1189
+ files_changed=data.get("files_changed", []),
1190
+ agent_id=review_agent_id,
1191
+ ticket_uuid=ticket_uuid,
1192
+ created_by=user,
1193
+ )
1194
+
1195
+ return JsonResponse(
1196
+ {
1197
+ "id": review.pk,
1198
+ "title": review.title,
1199
+ "description": review.description,
1200
+ "status": review.status,
1201
+ "agent_id": review.agent_id,
1202
+ "files_changed": review.files_changed,
1203
+ "created_at": _isoformat(review.created_at),
1204
+ },
1205
+ status=201,
1206
+ )
1207
+
1208
+
1209
+@csrf_exempt
1210
+def api_review_list(request, slug):
1211
+ """List code reviews for a repository, optionally filtered by status.
1212
+
1213
+ GET /projects/<slug>/fossil/api/reviews
1214
+ Optional query params: status (pending, approved, changes_requested, merged)
1215
+ """
1216
+ if request.method != "GET":
1217
+ return JsonResponse({"error": "GET required"}, status=405)
1218
+
1219
+ project, repo = _get_repo(slug)
1220
+ user, token, err = _check_api_auth(request, project, repo)
1221
+ if err is not None:
1222
+ return err
1223
+
1224
+ from fossil.code_reviews import CodeReview
1225
+
1226
+ qs = CodeReview.objects.filter(repository=repo)
1227
+ status_filter = request.GET.get("status", "").strip()
1228
+ if status_filter:
1229
+ qs = qs.filter(status=status_filter)
1230
+
1231
+ page, per_page = _paginate_params(request)
1232
+ total = qs.count()
1233
+ total_pages = max(1, math.ceil(total / per_page))
1234
+ page = min(page, total_pages)
1235
+ reviews_page = qs[(page - 1) * per_page : page * per_page]
1236
+
1237
+ reviews = []
1238
+ for r in reviews_page:
1239
+ reviews.append(
1240
+ {
1241
+ "id": r.pk,
1242
+ "title": r.title,
1243
+ "status": r.status,
1244
+ "agent_id": r.agent_id,
1245
+ "files_changed": r.files_changed,
1246
+ "comment_count": r.comments.count(),
1247
+ "created_at": _isoformat(r.created_at),
1248
+ "updated_at": _isoformat(r.updated_at),
1249
+ }
1250
+ )
1251
+
1252
+ return JsonResponse(
1253
+ {
1254
+ "reviews": reviews,
1255
+ "total": total,
1256
+ "page": page,
1257
+ "per_page": per_page,
1258
+ "total_pages": total_pages,
1259
+ }
1260
+ )
1261
+
1262
+
1263
+@csrf_exempt
1264
+def api_review_detail(request, slug, review_id):
1265
+ """Get a code review with its comments.
1266
+
1267
+ GET /projects/<slug>/fossil/api/reviews/<id>
1268
+ """
1269
+ if request.method != "GET":
1270
+ return JsonResponse({"error": "GET required"}, status=405)
1271
+
1272
+ project, repo = _get_repo(slug)
1273
+ user, token, err = _check_api_auth(request, project, repo)
1274
+ if err is not None:
1275
+ return err
1276
+
1277
+ from fossil.code_reviews import CodeReview
1278
+
1279
+ review = CodeReview.objects.filter(repository=repo, pk=review_id).first()
1280
+ if review is None:
1281
+ return JsonResponse({"error": "Review not found"}, status=404)
1282
+
1283
+ comments = []
1284
+ for c in review.comments.all():
1285
+ comments.append(
1286
+ {
1287
+ "id": c.pk,
1288
+ "body": c.body,
1289
+ "file_path": c.file_path,
1290
+ "line_number": c.line_number,
1291
+ "author": c.author,
1292
+ "created_at": _isoformat(c.created_at),
1293
+ }
1294
+ )
1295
+
1296
+ return JsonResponse(
1297
+ {
1298
+ "id": review.pk,
1299
+ "title": review.title,
1300
+ "description": review.description,
1301
+ "diff": review.diff,
1302
+ "status": review.status,
1303
+ "agent_id": review.agent_id,
1304
+ "files_changed": review.files_changed,
1305
+ "ticket_uuid": review.ticket_uuid,
1306
+ "workspace": review.workspace.name if review.workspace else None,
1307
+ "comments": comments,
1308
+ "created_at": _isoformat(review.created_at),
1309
+ "updated_at": _isoformat(review.updated_at),
1310
+ }
1311
+ )
1312
+
1313
+
1314
+@csrf_exempt
1315
+def api_review_comment(request, slug, review_id):
1316
+ """Add a comment to a code review.
1317
+
1318
+ POST /projects/<slug>/fossil/api/reviews/<id>/comment
1319
+ {
1320
+ "body": "This looks good but consider...",
1321
+ "file_path": "src/auth.py",
1322
+ "line_number": 42,
1323
+ "author": "human-reviewer"
1324
+ }
1325
+ """
1326
+ if request.method != "POST":
1327
+ return JsonResponse({"error": "POST required"}, status=405)
1328
+
1329
+ project, repo = _get_repo(slug)
1330
+ user, token, err = _check_api_auth(request, project, repo, required_scope="write")
1331
+ if err is not None:
1332
+ return err
1333
+
1334
+ from fossil.code_reviews import CodeReview, ReviewComment
1335
+
1336
+ review = CodeReview.objects.filter(repository=repo, pk=review_id).first()
1337
+ if review is None:
1338
+ return JsonResponse({"error": "Review not found"}, status=404)
1339
+
1340
+ try:
1341
+ data = json.loads(request.body)
1342
+ except (json.JSONDecodeError, ValueError):
1343
+ return JsonResponse({"error": "Invalid JSON body"}, status=400)
1344
+
1345
+ body = (data.get("body") or "").strip()
1346
+ if not body:
1347
+ return JsonResponse({"error": "Comment body is required"}, status=400)
1348
+
1349
+ # Determine author from auth context, not caller-supplied data
1350
+ if user:
1351
+ author = user.username
1352
+ elif token:
1353
+ author = f"token:{token.name}" if hasattr(token, "name") else "api-token"
1354
+ else:
1355
+ author = "anonymous"
1356
+
1357
+ comment = ReviewComment.objects.create(
1358
+ review=review,
1359
+ body=body,
1360
+ file_path=data.get("file_path", ""),
1361
+ line_number=data.get("line_number"),
1362
+ author=author,
1363
+ created_by=user,
1364
+ )
1365
+
1366
+ return JsonResponsepoints for programmatic access to Fossil repositories.
1367
+
1368
+All endpoints live under /projects/<slug>/fossil/api/.
1369
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1370
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
1371
+
1372
+All endpoints live under /projects/<slug>/fossil/api/.
1373
+Auth: Bearer tokser, project)):
1374
+ return JsonResponse({"error": "Write access required"}, status=403)
1375
+
1376
+ from fossil.code_reviews import CodeReview
1377
+
1378
+ review = CodeReview.objects.filter(repository=repo, pk=revicannot approve their own review.
1379
+ctory.get(full_path, da
1380
+ if review.status == "merged":
1381
+ return JsonResponse({"error": "Review is already merged"}, status=409)
1382
+
1383
+ if review.status != "approved":
1384
+ return JsonResponse({"error": "Review must be approved before merging"}, status=409)
1385
+
1386
+ review.status = "merged"
1387
+ review.save(update_fields=["status", "updated_at", "version"])
1388
+
1389
+ approver_agent_id andet_uuid).first()
1390
+ if claim and claim.status in ("claimed", "submitted"):
1391
+ claim.status = "merged"
1392
+ claim.save(update_fields=["status", "updated_at", "version"])
1393
+
1394
+ return JsonResponse({"id": review.pk, "status": review.statu return JsonResppoints for programmatic"""JSON API endauthor = (data.get("authorauthor andif not authorAuthor i=checkout_dir,
1395
+ )
1396
+ 0)points for programmatic access to Fossil repositories.
1397
+
1398
+All endpoints live under /projects/<slug>/fossil/api/.
1399
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1400
+All responses are JSON. All read endpoints check can_read_project.
1401
+"""
1402
+
1403
+import json
1404
+import logging
1405
+import math
1406
+import re
1407
+import shutil
1408
+import subprocess
1409
+import tempfile
1410
+import time
1411
+
1412
+from django.db import transaction
1413
+from django.http import JsonResponse, StreamingHttpResponse
1414
+from django.shortcuts import get_object_or_404
1415
+from django.test import RequestFactor"""JSON API endpoapprovlogger = logging.getLogger(__name__)
1416
+
1417
+
1418
+def _get_repo(slug):
1419
+ """Look up project and repository by slug, or return 404 JSON."""
1420
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1421
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1422
+ return project, repo
1423
+
1424
+
1425
+def _check_api_auth(request, project, repo, required_scope="read"):
1426
+ """Authenticate request and check access.
1427
+
1428
+ Args:
1429
+ require"""JSON API endpoints for programmatic access to Fossil repositories.
1430
+
1431
+All endpoints live under /projects/<slug>/fossil/api/.
1432
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1433
+All responses are JSON. A"""JSON API endpoints for programmatic access to Fossil repositories.
1434
+
1435
+All endpoints live under /projects/<slug>/fossil/api/.
1436
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookpoints for programmatic access to Fossil repositories.
1437
+
1438
+All endpoints live under /projects/<slug>/fossil/api/.
1439
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1440
+All responses are JSON. All read endpoints check can_read_project.
1441
+"""
1442
+
1443
+import json
1444
+import logging
1445
+import math
1446
+import re
1447
+import shutil
1448
+import subprocess
1449
+import tempfile
1450
+import time
1451
+
1452
+from django.db import transaction
1453
+from django.http import JsonResponse, StreamingHttpResponse
1454
+from django.shortcuts import get_object_or_404
1455
+from django.test import RequestFactory
1456
+from django.utils import timezone
1457
+from django.views.decorators.csrf import csrf_exempt
1458
+from django.views.decorators.http import require_GET
1459
+
1460
+from fossil.api_auth import authenticate_request
1461
+from fossil.models import FossilRepository
1462
+from fossil.reader import FossilReader
1463
+from projects.access import can_ort can_admin_project, can_read_project, can_write_project
1464
+from projects.models import Project
1465
+
1466
+logger = logging.getLogger(__name__)
1467
+
1468
+
1469
+def _get_repo(slug):
1470
+ """Look up project and repository by slug, or return 404 JSON."""
1471
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1472
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1473
+ """JSON API endpoints for programmatic access to Fossil repositories.
1474
+
1475
+All endpoints live under /projects/<slug>/fossil/api/.
1476
+Auth: Bearer token (APIToken or PersonalAccessToken) or session cookie.
1477
+All responses are JSON. All read endpoints check can_read_project.
1478
+"""
1479
+
1480
+import json
1481
+import logging
1482
+import math
1483
+import re
1484
+import shutil
1485
+import subprocess
1486
+import tempfile
1487
+import time
1488
+
1489
+from django.db import transaction
1490
+from django.http import JsonResponse, StreamingHttpResponse
1491
+from django.shortcuts import get_object_or_404
1492
+from django.test import RequestFactory
1493
+from django.utils import timezone
1494
+from django.views.decorators.csrf import csrf_exempt
1495
+from django.views.decorators.http import require_GET
1496
+
1497
+from fossil.api_auth import authenticate_request
1498
+from fossil.models import FossilRepository
1499
+from fossil.reader import FossilReader
1500
+from projects.access import can_ort can_admin_project, can_read_project, can_write_project
1501
+from projects.models import Project
1502
+
1503
+logger = logging.getLogger(__name__)
1504
+
1505
+
1506
+def _get_repo(slug):
1507
+ """Look up project and repository by slug, or return 404 JSON."""
1508
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1509
+ repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1510
+ return project, repo
1511
+
1512
+
1513
+def _check_api_auth(request, project, repo, required_scope="read"):
1514
+ """Authenticate request and check access.
1515
+
1516
+ Args:
1517
+ required_scope: "read" or "write" — enforced on both API tokens and PAT scopes.
1518
+
1519
+ Returns (user, token, error_response). If error_response is not None,
1520
+ the caller should return it immediately.
1521
+ """
1522
+ user, token, err = authenticate_request(request, repository=repo, required_scope=required_scope)
1523
+ if err is not None:
1524
+ return None, None, err
1525
+
1526
+ # For project-scoped APITokens (no user), the token itself grants access
1527
+ # since it's scoped to this repository and scope was already checked.
1528
+ if token is not None and user is None:
1529
+ return user, token, None
1530
+
1531
+ # For user-scoped auth (PAT or session), check project visibility
1532
+ if user is not None:
1533
+ if required_scope == "write" and not can_write_project(user, project):
1534
+ return None, None, JsonResponse({"error": "Write access required"}, status=403)
1535
+ if not can_read_project(user, project):
1536
+ return None, None, JsonResponse({"error": "Access denied"}, status=403)
1537
+
1538
+ return user, token, None
1539
+
1540
+
1541
+def _paginate_params(request, default_per_page=25, max_per_page=100):
1542
+ """Extract and validate page/per_page from query params."""
1543
+ try:
1544
+ page = max(1, int(request.GET.get("page", "1")))
1545
+ except (ValueError, TypeError):
1546
+ page = 1
1547
+ try:
1548
+ per_page = min(max_per_page, max(1, int(request.GET.get("per_page", str(default_per_page)))))
1549
+ except (ValueError, TypeError):
1550
+ per_page = default_per_page
1551
+ return page, per_page
1552
+
1553
+
1554
+def _isoformat(dt):
1555
+ """Safely format a datetime to ISO 8601, or None."""
1556
+ if dt is None:
1557
+ return None
1558
+ return dt.isoformat()
1559
+
1560
+
1561
+# --- API Documentation ---
1562
+
1563
+
1564
+@csrf_exempt
1565
+@require_GET
1566
+def api_docs(request, slug):
1567
+ """Return JSON listing all available API endpoints with descriptions."""
1568
+ base = f"/projects/{slug}/fossil/api"
1569
+ return JsonResponse(
1570
+ {
1571
+ "endpoints": [
1572
+ {"method": "GET", "path": f"{base}/project", "description": "Project metadata"},
1573
+ {
1574
+ "method": "GET",
1575
+ "path": f"{base}/timeline",
1576
+ "description": "Recent checkins (paginated)",
1577
+ "params": "page, per_page, branch",
1578
+ },
1579
+ {
1580
+ "method": "GET",
1581
+ "path": f"{base}/tickets",
1582
+ "description": "Ticket list (paginated, filterable)",
1583
+ "params": "page, per_page, status",
1584
+ },
1585
+ {"method": "GET", "path": f"{base}/tickets/<uuid>", "description": "Single ticket detail with comments"},
1586
+ {"method": "GET", "path": f"{base}/wiki", "description": "Wiki page list"},
1587
+ {"method": "GET", "path": f"{base}/wiki/<name>", "description": "Single wiki page with content"},
1588
+ {"method": "GET", "path": f"{base}/branches", "description": "Branch list"},
1589
+ {"method": "GET", "path": f"{base}/tags", "description": "Tag list"},
1590
+ {"method": "GET", "path": f"{base}/releases", "description": "Release list"},
1591
+ {"method": "GET", "path": f"{base}/search", "description": "Search across checkins, tickets, wiki", "params": "q"},
1592
+ {
1593
+ "method": "POST",
1594
+ "path": f"{base}/batch",
1595
+ "description": "Execute multiple API calls in a single request (max 25)",
1596
+ "body": '{"requests": [{"method": "GET", "path": "/api/timeline", "params": {}}]}',
1597
+ },
1598
+ {"method": "GET", "path": f"{base}/workspaces", "description": "List agent workspaces", "params": "status"},
1599
+ {
1600
+ "method": "POST",
1601
+ "path": f"{base}/workspaces/create",
1602
+ "description": "Create an isolated agent workspace",
1603
+ "body": '{"name": "...", "description": "...", "agent_id": "..."}',
1604
+ },
1605
+ {"method": "GET", "path": f"{base}/workspaces/<name>", "description": "Get workspace details"},
1606
+ {
1607
+ "method": "POST",
1608
+ "path": f"{base}/workspaces/<name>/commit",
1609
+ "description": "Commit changes in a workspace",
1610
+ "body": '{"message": "...", "files": []}',
1611
+ },
1612
+ {
1613
+ "method": "POST",
1614
+ "path": f"{base}/workspaces/<name>/merge",
1615
+ "description": "Merge workspace branch back to trunk",
1616
+ "body": '{"target_branch": "trunk"}',
1617
+ },
1618
+ {
1619
+ "method": "DELETE",
1620
+ "path": f"{base}/workspaces/<name>/abandon",
1621
+ "description": "Abandon and clean up a workspace",
1622
+ },
1623
+ {
1624
+ "method": "POST",
1625
+ "path": f"{base}/tickets/<uuid>/claim",
1626
+ "description": "Claim a ticket for exclusive agent work",
1627