Compare commits
482 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
111e403ebd | ||
|
|
d4e0b1b27f | ||
|
|
b95f3c22be | ||
|
|
b1ac8ce102 | ||
|
|
6dad9432d0 | ||
|
|
48b5210e8d | ||
|
|
85fed3d1d9 | ||
|
|
14b5eb1c31 | ||
|
|
c0bbf378f3 | ||
|
|
65fce91a58 | ||
|
|
bdf545ff87 | ||
|
|
a3a596bbdb | ||
|
|
96a044450f | ||
|
|
17ac65a493 | ||
|
|
9ac649105a | ||
|
|
048356dddf | ||
|
|
367afab430 | ||
|
|
75a796a43d | ||
|
|
43776b123c | ||
|
|
bf78943f3d | ||
|
|
98db5f2e12 | ||
|
|
257db646a6 | ||
|
|
ae100c0b34 | ||
|
|
80841e409f | ||
|
|
d1b2a25bf7 | ||
|
|
94e9036e87 | ||
|
|
33f977293d | ||
|
|
ff4c9bb26c | ||
|
|
6085409bad | ||
|
|
cf71b3c495 | ||
|
|
4dd36fe98a | ||
|
|
1bc1dd8460 | ||
|
|
87bf4b8ca3 | ||
|
|
07975c4fbe | ||
|
|
a2884a9891 | ||
|
|
00a585e2c1 | ||
|
|
9b6f5c960a | ||
|
|
c27f4be6cb | ||
|
|
36de8c2313 | ||
|
|
b30b8eb934 | ||
|
|
cabb330966 | ||
|
|
7a3abe9fa2 | ||
|
|
bd56e398e5 | ||
|
|
def7626b37 | ||
|
|
38c4d071c9 | ||
|
|
8bf6bde38d | ||
|
|
04f08c1b1c | ||
|
|
e26f39ee5b | ||
|
|
bb7957cdde | ||
|
|
7741bd5ccc | ||
|
|
2678ea2e8a | ||
|
|
cd2cf7fb06 | ||
|
|
cd34bcddc9 | ||
|
|
ed86765321 | ||
|
|
2f96d35657 | ||
|
|
88a69fdaaf | ||
|
|
d7045aba2a | ||
|
|
071f32ddea | ||
|
|
688cec90a8 | ||
|
|
11daed18d7 | ||
|
|
5a62121591 | ||
|
|
b55d277406 | ||
|
|
49d8d2ea7b | ||
|
|
62a23b5fb0 | ||
|
|
b6a9e2aa54 | ||
|
|
fcfcc3e127 | ||
|
|
64de972713 | ||
|
|
1df16f0fe2 | ||
|
|
e611c03625 | ||
|
|
03329c10ed | ||
|
|
db86c985f5 | ||
|
|
c9d1af8067 | ||
|
|
9001a43375 | ||
|
|
54d077fcb8 | ||
|
|
2623b6818f | ||
|
|
f4f80b9ed7 | ||
|
|
de26b280d0 | ||
|
|
48a45b60fa | ||
|
|
7f668b51f9 | ||
|
|
03d8a03f7c | ||
|
|
d8285c772e | ||
|
|
4519cacb44 | ||
|
|
857a6c7bcf | ||
|
|
ebe2887d7c | ||
|
|
262ce18902 | ||
|
|
94ff7f1007 | ||
|
|
425bfcb495 | ||
|
|
8400e97d17 | ||
|
|
770a818f74 | ||
|
|
e53731fd75 | ||
|
|
d73311e74c | ||
|
|
2a4955b934 | ||
|
|
06ece6dc1b | ||
|
|
abc60399d8 | ||
|
|
5dd6d79d4c | ||
|
|
ad882ae509 | ||
|
|
ce29077e8c | ||
|
|
7c86be2098 | ||
|
|
38bf8013cd | ||
|
|
bdc971927f | ||
|
|
c91eaa7db9 | ||
|
|
3854234a3c | ||
|
|
0617d258f6 | ||
|
|
f353e634d2 | ||
|
|
b041cd5440 | ||
|
|
3fb556c8f1 | ||
|
|
904e909ce1 | ||
|
|
2205536549 | ||
|
|
0b07522d7c | ||
|
|
d471634f30 | ||
|
|
9aa1667e9c | ||
|
|
565b9fed6f | ||
|
|
7a58769139 | ||
|
|
a451ae8a98 | ||
|
|
1c1606d61d | ||
|
|
6121acdc4b | ||
|
|
26fa1712ec | ||
|
|
9bc03f6db8 | ||
|
|
d5aecdfba8 | ||
|
|
5fa436f204 | ||
|
|
bd4796116e | ||
|
|
0aea602122 | ||
|
|
7582f27899 | ||
|
|
532848ebdf | ||
|
|
387f39cbcc | ||
|
|
4d1e83e9e7 | ||
|
|
a88121d465 | ||
|
|
86f0cb188e | ||
|
|
92bafe57fb | ||
|
|
b25dd0a10c | ||
|
|
0d41cdeebf | ||
|
|
083ab8c95d | ||
|
|
62d976eae5 | ||
|
|
13a5310ba5 | ||
|
|
1eae6d5713 | ||
|
|
0311122602 | ||
|
|
93bc0e582a | ||
|
|
d3c174f623 | ||
|
|
5de1ab0b18 | ||
|
|
15de51feb8 | ||
|
|
08fc0906c0 | ||
|
|
e7b222bc7d | ||
|
|
1f3b70ba74 | ||
|
|
290556e602 | ||
|
|
927d35c984 | ||
|
|
aec4fd0272 | ||
|
|
91a9759874 | ||
|
|
cb560366b8 | ||
|
|
132f0cae6c | ||
|
|
aa0983eba3 | ||
|
|
143675bad0 | ||
|
|
edf83de94c | ||
|
|
edced8825d | ||
|
|
88f1f5d87e | ||
|
|
9286fe4dd8 | ||
|
|
f346b628ed | ||
|
|
b819fee208 | ||
|
|
cbb18854da | ||
|
|
bb8cb5a23d | ||
|
|
73db23f590 | ||
|
|
8fd0ded519 | ||
|
|
c29bd89a0a | ||
|
|
02e4eeec07 | ||
|
|
d482c533d7 | ||
|
|
db331ef4ca | ||
|
|
f44d64b7ad | ||
|
|
7ed915555b | ||
|
|
a321104b4c | ||
|
|
119e8e0736 | ||
|
|
e4931d4e03 | ||
|
|
ea5c297641 | ||
|
|
82eba17a82 | ||
|
|
421e95d83b | ||
|
|
cca21c09d5 | ||
|
|
c7aab0b026 | ||
|
|
228a5c9644 | ||
|
|
acfd882bba | ||
|
|
5f7d831bdd | ||
|
|
1cf778366f | ||
|
|
d8133347f0 | ||
|
|
b703a209d0 | ||
|
|
896f475b03 | ||
|
|
49c578cef2 | ||
|
|
44081099c4 | ||
|
|
828b63c8c8 | ||
|
|
6bbdc157cb | ||
|
|
4770380e38 | ||
|
|
a9c49a0282 | ||
|
|
692e7767f3 | ||
|
|
b3d1e40130 | ||
|
|
cd3c91f93e | ||
|
|
41898dfcc7 | ||
|
|
17cc47f22d | ||
|
|
deb8b50bb9 | ||
|
|
65ad05ac76 | ||
|
|
3a80ad4f15 | ||
|
|
032b287548 | ||
|
|
4f9230cdc2 | ||
|
|
be09b5830e | ||
|
|
5cf3b1d997 | ||
|
|
4786e5b5d9 | ||
|
|
c0a3e4d8c3 | ||
|
|
56f3de5d0d | ||
|
|
d387f6c4d3 | ||
|
|
9654497a2b | ||
|
|
01d3ef8a46 | ||
|
|
1f7f4f33cc | ||
|
|
c1c8f837a8 | ||
|
|
f53424aaa4 | ||
|
|
aaa0062568 | ||
|
|
b57b1ab030 | ||
|
|
d7ae944aee | ||
|
|
5012c9a1b5 | ||
|
|
c3e3356574 | ||
|
|
c7e740e732 | ||
|
|
273976a69e | ||
|
|
3dd4a9b692 | ||
|
|
c675cb9125 | ||
|
|
76ad2f4581 | ||
|
|
4c8ef754c0 | ||
|
|
25b053ee13 | ||
|
|
960d2c8805 | ||
|
|
56d8054d90 | ||
|
|
b4e9829f04 | ||
|
|
48c9c19a64 | ||
|
|
e10e499b6c | ||
|
|
bfe2d1d521 | ||
|
|
26cbe83d66 | ||
|
|
98e7bbdb9e | ||
|
|
20bc0ac757 | ||
|
|
a03b0dfe33 | ||
|
|
73e757a86b | ||
|
|
6c4a5555f3 | ||
|
|
d41566f4dc | ||
|
|
800d0c3020 | ||
|
|
968065a0a3 | ||
|
|
cb5f4d0e83 | ||
|
|
898922efd1 | ||
|
|
014b77a27e | ||
|
|
ab6c6b2ff8 | ||
|
|
bdba13cb60 | ||
|
|
0c1e470779 | ||
|
|
0d88d8e58e | ||
|
|
3e7cc12b32 | ||
|
|
d806fb806c | ||
|
|
25790fd157 | ||
|
|
00b4436013 | ||
|
|
0a23133043 | ||
|
|
66ec456102 | ||
|
|
1c1e6a570c | ||
|
|
8a1660b781 | ||
|
|
d9a651b375 | ||
|
|
979091f975 | ||
|
|
712abbb34f | ||
|
|
a219b8c1a2 | ||
|
|
2c565c3b50 | ||
|
|
6ee244b829 | ||
|
|
893526eeac | ||
|
|
c2919facfd | ||
|
|
c6eb497bfa | ||
|
|
072564fb0f | ||
|
|
0b2a9bad2f | ||
|
|
13c5aed435 | ||
|
|
2cdc55ba2f | ||
|
|
b27cc5eaf0 | ||
|
|
b006187320 | ||
|
|
c2db24133b | ||
|
|
46cf6531d9 | ||
|
|
8dcf1fab1e | ||
|
|
f94f36350f | ||
|
|
4285baecbf | ||
|
|
fc96a6a8e0 | ||
|
|
1fa76638a6 | ||
|
|
344395cb2d | ||
|
|
2aaf611edb | ||
|
|
9614c0e950 | ||
|
|
e97587634e | ||
|
|
3dc551b383 | ||
|
|
3619dd2234 | ||
|
|
453b60032c | ||
|
|
0f2aa9c49f | ||
|
|
92a33adae5 | ||
|
|
187f238e02 | ||
|
|
70b5b1a4f3 | ||
|
|
3d5593ca61 | ||
|
|
2428dae1cc | ||
|
|
a5dd8d8f39 | ||
|
|
5434a9b39d | ||
|
|
fc06f5e36a | ||
|
|
40f7eeee09 | ||
|
|
de720cbdcb | ||
|
|
aea5ba9e58 | ||
|
|
c38ae1bd78 | ||
|
|
3757368a00 | ||
|
|
5cb86d4fde | ||
|
|
94cb7bc99f | ||
|
|
efa7e66b36 | ||
|
|
3382e5496e | ||
|
|
95b0ff51b6 | ||
|
|
07304c3f73 | ||
|
|
f2cefee7e4 | ||
|
|
768f61337b | ||
|
|
744fbf87f5 | ||
|
|
bc9c7a06c8 | ||
|
|
46dd299229 | ||
|
|
03962d5a80 | ||
|
|
847a8de414 | ||
|
|
c00c1e273d | ||
|
|
47e94995b5 | ||
|
|
804e994e76 | ||
|
|
294b28d1a8 | ||
|
|
233f1dd315 | ||
|
|
d2c26e6203 | ||
|
|
37e0520af0 | ||
|
|
6ce43a3c9b | ||
|
|
6943e95479 | ||
|
|
2e1096a9ad | ||
|
|
8d9db29db2 | ||
|
|
afeb7a35ad | ||
|
|
70039a8288 | ||
|
|
634aa521bd | ||
|
|
749672509b | ||
|
|
20e9ae3e4d | ||
|
|
3738b346e3 | ||
|
|
0aa281ac09 | ||
|
|
8baaad2c08 | ||
|
|
19825763ad | ||
|
|
c16cd49237 | ||
|
|
57eea172a2 | ||
|
|
d255f40a8c | ||
|
|
d59a655f0f | ||
|
|
509983b4ff | ||
|
|
5fb08d8831 | ||
|
|
18f5b3f98b | ||
|
|
d58137716f | ||
|
|
18138dde01 | ||
|
|
6db2bf077d | ||
|
|
42ee3057bf | ||
|
|
350eb27e30 | ||
|
|
2ded9b00f9 | ||
|
|
2daa9e9855 | ||
|
|
2fa5f0441a | ||
|
|
64690231c2 | ||
|
|
0ee3b32ef3 | ||
|
|
99419748bb | ||
|
|
89f55471ce | ||
|
|
ef60983534 | ||
|
|
8e06e7e17d | ||
|
|
0b822af438 | ||
|
|
69cf328972 | ||
|
|
181fb0af93 | ||
|
|
7bd1ab16da | ||
|
|
d52307c792 | ||
|
|
dbbad29f2f | ||
|
|
ec25f06ea3 | ||
|
|
36db2daabd | ||
|
|
ff51ef8a71 | ||
|
|
83f0fa0ecb | ||
|
|
1b20fe8b7f | ||
|
|
3e0094b11c | ||
|
|
f0fe439c3b | ||
|
|
d9c8c1e1f3 | ||
|
|
31ab3aad45 | ||
|
|
923b0ede86 | ||
|
|
80411d9231 | ||
|
|
675c2bf95d | ||
|
|
6ffe9aa4df | ||
|
|
1680914017 | ||
|
|
60df1a38ff | ||
|
|
af5c8f0e72 | ||
|
|
0378a0748a | ||
|
|
c330381281 | ||
|
|
6a4cd8c672 | ||
|
|
09d16abcd5 | ||
|
|
330bd86b96 | ||
|
|
d6277f5469 | ||
|
|
c8eb0aefe3 | ||
|
|
17d10e5815 | ||
|
|
ef737429d6 | ||
|
|
2129d72a1f | ||
|
|
3d80072e7b | ||
|
|
5ef5623c8d | ||
|
|
b2e5eef66f | ||
|
|
68270b6906 | ||
|
|
9d466fd63a | ||
|
|
619b2fe23c | ||
|
|
ac1bd2749c | ||
|
|
a3a9360110 | ||
|
|
f0d6d0b8e1 | ||
|
|
b09cdde8d3 | ||
|
|
e5aee55dca | ||
|
|
615c92dc5f | ||
|
|
bd6dd68f0b | ||
|
|
e9517a5a4d | ||
|
|
39e0eb0efd | ||
|
|
a51c0e070c | ||
|
|
56526c8fc5 | ||
|
|
1151abab28 | ||
|
|
35b2095544 | ||
|
|
7259aa41fd | ||
|
|
9952323252 | ||
|
|
d3ed7fd4f3 | ||
|
|
fd4224d1da | ||
|
|
ed22c5eda4 | ||
|
|
acb9b5f71c | ||
|
|
e34d09f762 | ||
|
|
9b03695d61 | ||
|
|
e36a4e5785 | ||
|
|
7cf55325d4 | ||
|
|
6186c4c779 | ||
|
|
9b10b1f6f1 | ||
|
|
0ce9185373 | ||
|
|
4a4e03e041 | ||
|
|
18600a6bc1 | ||
|
|
2f20241ba6 | ||
|
|
7aaca1d1f4 | ||
|
|
185cbc83b9 | ||
|
|
e64925b438 | ||
|
|
6c864612db | ||
|
|
288363d2da | ||
|
|
54fa429e3e | ||
|
|
0f13b6c01d | ||
|
|
f372172898 | ||
|
|
fa6ba846d8 | ||
|
|
1366807b25 | ||
|
|
22868b2958 | ||
|
|
84c7ed0815 | ||
|
|
941d8bf736 | ||
|
|
b381a46483 | ||
|
|
5669385616 | ||
|
|
0797b407a5 | ||
|
|
f3656fdbd0 | ||
|
|
687c7a5a61 | ||
|
|
6a3a2f6701 | ||
|
|
f86c44224e | ||
|
|
7f6e7f7a10 | ||
|
|
cadf504643 | ||
|
|
1ceb27dea8 | ||
|
|
11436e93c5 | ||
|
|
7f6fe23219 | ||
|
|
c8cd82edb4 | ||
|
|
ff370af5a1 | ||
|
|
ca39b3dee4 | ||
|
|
260b8b180e | ||
|
|
4f67ead22d | ||
|
|
14a86b8ae1 | ||
|
|
708d4ca790 | ||
|
|
aac6d2eb46 | ||
|
|
d20840f4a6 | ||
|
|
a1ff88bfd9 | ||
|
|
cfeae0a199 | ||
|
|
bb33ad1241 | ||
|
|
3ec4f9d331 | ||
|
|
2a70212123 | ||
|
|
b3b31f9c4c | ||
|
|
e6c87f6044 | ||
|
|
6866f7fdab | ||
|
|
8114a3746b | ||
|
|
a9d205f454 | ||
|
|
a7b29c990b | ||
|
|
9daccbae82 | ||
|
|
2ab0786e30 | ||
|
|
7489c7b1e7 | ||
|
|
f18e779375 | ||
|
|
2a62e13fc7 | ||
|
|
e16b1bb083 | ||
|
|
ebb97f4f39 | ||
|
|
e9df8f7d9f | ||
|
|
6a0c5780fd | ||
|
|
2876d9980f | ||
|
|
332b89e348 | ||
|
|
b99898815a | ||
|
|
bd5c424786 | ||
|
|
5caf3f6b62 | ||
|
|
b18a171ac2 | ||
|
|
37e1c1a616 | ||
|
|
6a01fe84ac | ||
|
|
7adde54a41 | ||
|
|
1eb8781550 | ||
|
|
352e3ee6c5 | ||
|
|
1930eb37fb | ||
|
|
912fc99e9e |
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.tar.gz
|
||||
*_bak.tar.gz
|
||||
EUROSCALE_DEPLOYMENT_BLUEPRINT.md
|
||||
224
README.md
@@ -1,149 +1,173 @@
|
||||
# b0esche.cloud
|
||||
|
||||
A self-hosted, SaaS-style document platform with Go backend and Flutter web frontend.
|
||||
A self-hosted, SaaS-style cloud storage and document platform with a Go backend and Flutter web frontend.
|
||||
|
||||
🌐 **Live:** [b0esche.cloud](https://b0esche.cloud)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Flutter Web │────▶│ Go Backend │────▶│ PostgreSQL │
|
||||
│ (b0esche_cloud)│ │ (go_cloud) │ │ │
|
||||
└─────────────────┘ └────────┬────────┘ └─────────────────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│Nextcloud │ │Collabora │ │ Traefik │
|
||||
│(Storage) │ │ (Office) │ │ (Proxy) │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `go_cloud/`: Go backend (control plane) with REST API
|
||||
- `b0esche_cloud/`: Flutter web frontend with BLoC architecture
|
||||
- Supporting services: Nextcloud (storage), Collabora (editing), PostgreSQL (database)
|
||||
```
|
||||
b0esche_cloud/
|
||||
├── b0esche_cloud/ # Flutter web frontend
|
||||
│ ├── lib/
|
||||
│ │ ├── blocs/ # BLoC state management
|
||||
│ │ ├── models/ # Data models
|
||||
│ │ ├── pages/ # UI pages
|
||||
│ │ ├── repositories/ # Data repositories
|
||||
│ │ ├── services/ # API services
|
||||
│ │ ├── theme/ # App theming
|
||||
│ │ └── widgets/ # Reusable widgets
|
||||
│ └── web/ # Web assets
|
||||
├── go_cloud/ # Go backend
|
||||
│ ├── cmd/api/ # Main entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── auth/ # Authentication (OIDC, Passkeys)
|
||||
│ │ ├── files/ # File management
|
||||
│ │ ├── org/ # Organization management
|
||||
│ │ ├── storage/ # Nextcloud/WebDAV integration
|
||||
│ │ ├── http/ # HTTP handlers & WOPI
|
||||
│ │ └── ...
|
||||
│ ├── migrations/ # Database migrations
|
||||
│ └── pkg/jwt/ # JWT utilities
|
||||
├── scripts/ # Deployment & operations scripts
|
||||
└── docs/ # Documentation
|
||||
└── AUTH.md # Authentication system docs
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Authentication**: OIDC via Nextcloud + WebAuthn Passkeys
|
||||
- 📁 **File Management**: Upload, download, organize files
|
||||
- 👥 **Organizations**: Multi-tenant with roles (Owner, Admin, Member)
|
||||
- 📝 **Document Viewing**: PDF viewer, Office document preview
|
||||
- 🔄 **Real-time Sync**: Nextcloud/WebDAV backend storage
|
||||
- 🚀 **Auto-deployment**: Daily 3AM deployments via GitLab webhooks
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.21+
|
||||
- Flutter 3.10+
|
||||
- Docker and Docker Compose
|
||||
- PostgreSQL (or Docker)
|
||||
- Nextcloud instance
|
||||
- Collabora Online instance
|
||||
- Docker & Docker Compose
|
||||
- PostgreSQL 15+
|
||||
|
||||
## Local Development Setup
|
||||
## Local Development
|
||||
|
||||
### 1. Start Supporting Services
|
||||
|
||||
Use Docker Compose to start PostgreSQL, Nextcloud, and Collabora:
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
docker-compose up -d db nextcloud collabora
|
||||
# Start everything
|
||||
./scripts/dev-all.sh
|
||||
```
|
||||
|
||||
### 2. Backend Setup
|
||||
### Manual Setup
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
cd go_cloud
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration (DB URL, Nextcloud URL, etc.)
|
||||
# Edit .env with your configuration
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
Or use the provided script:
|
||||
|
||||
```bash
|
||||
./scripts/dev-backend.sh
|
||||
```
|
||||
|
||||
### 3. Frontend Setup
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd b0esche_cloud
|
||||
flutter pub get
|
||||
flutter run -d chrome
|
||||
```
|
||||
|
||||
Or use the script:
|
||||
|
||||
```bash
|
||||
./scripts/dev-frontend.sh
|
||||
```
|
||||
|
||||
### 4. Full Development Environment
|
||||
|
||||
To start everything:
|
||||
|
||||
```bash
|
||||
./scripts/dev-all.sh
|
||||
```
|
||||
|
||||
This will bring up all services, backend, and frontend.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend (.env)
|
||||
### Backend Environment Variables
|
||||
|
||||
Copy `go_cloud/.env.example` to `go_cloud/.env` and fill in:
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SERVER_ADDR` | Server address (default: `:8080`) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | Secret for JWT signing |
|
||||
| `OIDC_ISSUER_URL` | OIDC provider URL |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret |
|
||||
| `NEXTCLOUD_URL` | Nextcloud instance URL |
|
||||
| `NEXTCLOUD_USERNAME` | Nextcloud admin username |
|
||||
| `NEXTCLOUD_PASSWORD` | Nextcloud admin password |
|
||||
| `COLLABORA_URL` | Collabora Online URL |
|
||||
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
- `JWT_SECRET`: Random secret for JWT signing
|
||||
- `OIDC_*`: OIDC provider settings
|
||||
- `NEXTCLOUD_*`: Nextcloud API settings
|
||||
- `COLLABORA_*`: Collabora settings
|
||||
## Production Deployment
|
||||
|
||||
### Frontend
|
||||
The project runs on a VPS with Docker containers behind Traefik reverse proxy.
|
||||
|
||||
The frontend uses build-time environment variables for API base URL. For dev, it's hardcoded in `ApiClient` constructor.
|
||||
### Services & Domains
|
||||
|
||||
For production builds, update accordingly.
|
||||
| Domain | Service |
|
||||
|--------|---------|
|
||||
| `www.b0esche.cloud` | Flutter Web (Nginx) |
|
||||
| `go.b0esche.cloud` | Go API Backend |
|
||||
| `storage.b0esche.cloud` | Nextcloud (Storage + OIDC) |
|
||||
| `of.b0esche.cloud` | Collabora Online (Office) |
|
||||
|
||||
## Running Tests
|
||||
### Server Directory Structure
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd go_cloud
|
||||
go test ./...
|
||||
```
|
||||
/opt/
|
||||
├── traefik/ # Reverse proxy + SSL
|
||||
├── go/ # Go backend + PostgreSQL
|
||||
├── flutter/ # Flutter web build + Nginx
|
||||
├── scripts/ # Operations scripts
|
||||
└── auto-deploy/ # Auto-deployment workspace
|
||||
```
|
||||
|
||||
### Frontend
|
||||
### Server Scripts
|
||||
|
||||
```bash
|
||||
cd b0esche_cloud
|
||||
flutter test
|
||||
```
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `auto-deploy.sh` | Daily automated deployment (runs at 3AM) |
|
||||
| `deploy-now.sh` | Trigger immediate deployment |
|
||||
| `backup.sh` | Full backup (DB, configs, volumes) |
|
||||
| `monitor.sh` | Health monitoring & alerts |
|
||||
| `webhook-server.py` | GitLab webhook receiver |
|
||||
|
||||
## Building for Production
|
||||
|
||||
### Backend
|
||||
## Tech Stack
|
||||
|
||||
```bash
|
||||
cd go_cloud
|
||||
go build -o bin/api ./cmd/api
|
||||
```
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Frontend | Flutter Web, BLoC |
|
||||
| Backend | Go, Chi Router |
|
||||
| Database | PostgreSQL |
|
||||
| Storage | Nextcloud (WebDAV) |
|
||||
| Office | Collabora Online |
|
||||
| Auth | OIDC, WebAuthn |
|
||||
| Proxy | Traefik |
|
||||
| CI/CD | GitLab + Webhooks |
|
||||
|
||||
### Frontend
|
||||
## Documentation
|
||||
|
||||
```bash
|
||||
cd b0esche_cloud
|
||||
flutter build web
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Migrations are in `go_cloud/migrations/`.
|
||||
|
||||
To apply:
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
go run github.com/pressly/goose/v3/cmd/goose@latest postgres "$DATABASE_URL" up
|
||||
|
||||
# Production
|
||||
# Use your deployment tool to run the migration command
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
- **Database**: Regular PostgreSQL dumps of orgs, memberships, activities
|
||||
- **Files**: Nextcloud/S3 backups handled at storage layer
|
||||
- **Recovery**: Restore DB, then files; Go control plane is stateless
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Clone the repo
|
||||
2. Follow local setup
|
||||
3. Make changes
|
||||
4. Run tests
|
||||
5. Submit PR
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture, components, data flows |
|
||||
| [API.md](docs/API.md) | Complete API endpoint reference |
|
||||
| [AUTH.md](docs/AUTH.md) | Authentication system (Passkeys, OIDC, roles) |
|
||||
| [SECURITY.md](docs/SECURITY.md) | Security architecture, hardening, best practices |
|
||||
| [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local setup, coding conventions, testing |
|
||||
| [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment, operations, troubleshooting |
|
||||
|
||||
## License
|
||||
|
||||
[License here]
|
||||
Private project - All rights reserved
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# b0esche_cloud
|
||||
|
||||
A new Flutter project.
|
||||
b0esche secure cloud
|
||||
|
||||
BIN
b0esche_cloud/assets/icons/b0esche-cloud-icon-cut.png
Normal file
|
After Width: | Height: | Size: 589 KiB |
BIN
b0esche_cloud/assets/icons/b0esche-cloud-icon-sharp.png
Normal file
|
After Width: | Height: | Size: 574 KiB |
BIN
b0esche_cloud/assets/icons/b0esche-cloud-icon.png
Normal file
|
After Width: | Height: | Size: 731 KiB |
@@ -1,10 +1,12 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import '../session/session_bloc.dart';
|
||||
import '../session/session_event.dart';
|
||||
import '../session/session_state.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'auth_state.dart';
|
||||
import '../../services/api_client.dart';
|
||||
import '../../models/api_error.dart';
|
||||
import '../../models/user.dart';
|
||||
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final ApiClient apiClient;
|
||||
@@ -21,6 +23,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
|
||||
on<LogoutRequested>(_onLogoutRequested);
|
||||
on<CheckAuthRequested>(_onCheckAuthRequested);
|
||||
on<UpdateUserProfile>(_onUpdateUserProfile);
|
||||
}
|
||||
|
||||
Future<void> _onSignupStarted(
|
||||
@@ -53,7 +56,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
add(RegistrationChallengeRequested(userId: userId));
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +83,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,17 +112,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
sessionBloc.add(SessionStarted(token));
|
||||
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
),
|
||||
);
|
||||
// Fetch full profile and include it in state when possible
|
||||
try {
|
||||
final profile = await apiClient.getUserProfile();
|
||||
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
user: fullUser,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +151,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
add(AuthenticationChallengeRequested(username: event.username));
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +181,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,17 +210,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
sessionBloc.add(SessionStarted(token));
|
||||
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
),
|
||||
);
|
||||
// Fetch full profile and include it in state when possible
|
||||
try {
|
||||
final profile = await apiClient.getUserProfile();
|
||||
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
user: fullUser,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,17 +266,34 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final user = response['user'];
|
||||
|
||||
sessionBloc.add(SessionStarted(token));
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
),
|
||||
);
|
||||
|
||||
// Fetch full profile and include it in state when possible
|
||||
try {
|
||||
final profile = await apiClient.getUserProfile();
|
||||
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
user: fullUser,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: token,
|
||||
userId: user['id'],
|
||||
username: user['username'],
|
||||
email: user['email'],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +308,72 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
CheckAuthRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Check if token is valid in SessionBloc
|
||||
emit(AuthUnauthenticated());
|
||||
// Check if session is active from persistent storage
|
||||
final sessionState = sessionBloc.state;
|
||||
|
||||
if (sessionState is SessionActive) {
|
||||
// Try to fetch full profile immediately so UI can show avatar/displayName
|
||||
try {
|
||||
final profile = await apiClient.getUserProfile();
|
||||
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: sessionState.token,
|
||||
userId: fullUser?.id ?? '',
|
||||
username: fullUser?.username ?? '',
|
||||
email: fullUser?.email ?? '',
|
||||
user: fullUser,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Fall back to minimal authenticated state if profile fetch fails
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: sessionState.token,
|
||||
userId: '',
|
||||
username: '',
|
||||
email: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpdateUserProfile(
|
||||
UpdateUserProfile event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is AuthAuthenticated) {
|
||||
final currentState = state as AuthAuthenticated;
|
||||
// Try to reload profile from backend to ensure we have canonical avatar URL (with token+version)
|
||||
try {
|
||||
final profile = await apiClient.getUserProfile();
|
||||
final fullUser = profile.isNotEmpty
|
||||
? User.fromJson(profile)
|
||||
: event.updatedUser;
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: currentState.token,
|
||||
userId: fullUser.id,
|
||||
username: fullUser.username,
|
||||
email: fullUser.email,
|
||||
user: fullUser,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Fallback to using the provided updatedUser
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: currentState.token,
|
||||
userId: event.updatedUser.id,
|
||||
username: event.updatedUser.username,
|
||||
email: event.updatedUser.email,
|
||||
user: event.updatedUser,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../models/user.dart';
|
||||
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
@@ -135,3 +136,12 @@ class PasswordLoginRequested extends AuthEvent {
|
||||
@override
|
||||
List<Object> get props => [username, password];
|
||||
}
|
||||
|
||||
class UpdateUserProfile extends AuthEvent {
|
||||
final User updatedUser;
|
||||
|
||||
const UpdateUserProfile(this.updatedUser);
|
||||
|
||||
@override
|
||||
List<Object> get props => [updatedUser];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../models/user.dart';
|
||||
|
||||
abstract class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
@@ -68,25 +69,28 @@ class AuthAuthenticated extends AuthState {
|
||||
final String userId;
|
||||
final String username;
|
||||
final String email;
|
||||
final User? user;
|
||||
|
||||
const AuthAuthenticated({
|
||||
required this.token,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.email,
|
||||
this.user,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [token, userId, username, email];
|
||||
List<Object?> get props => [token, userId, username, email, user];
|
||||
}
|
||||
|
||||
class AuthFailure extends AuthState {
|
||||
final String error;
|
||||
final String? code;
|
||||
|
||||
const AuthFailure(this.error);
|
||||
const AuthFailure(this.error, {this.code});
|
||||
|
||||
@override
|
||||
List<Object> get props => [error];
|
||||
List<Object?> get props => [error, code];
|
||||
}
|
||||
|
||||
class AuthUnauthenticated extends AuthState {
|
||||
|
||||
@@ -34,6 +34,8 @@ class DocumentViewerBloc
|
||||
DocumentViewerReady(
|
||||
viewUrl: session.viewUrl,
|
||||
caps: session.capabilities,
|
||||
token: session.token,
|
||||
fileInfo: session.fileInfo,
|
||||
),
|
||||
);
|
||||
_expiryTimer = Timer(
|
||||
|
||||
@@ -15,11 +15,23 @@ class DocumentViewerLoading extends DocumentViewerState {}
|
||||
class DocumentViewerReady extends DocumentViewerState {
|
||||
final Uri viewUrl;
|
||||
final DocumentCapabilities caps;
|
||||
final String token;
|
||||
final FileInfo? fileInfo;
|
||||
|
||||
const DocumentViewerReady({required this.viewUrl, required this.caps});
|
||||
const DocumentViewerReady({
|
||||
required this.viewUrl,
|
||||
required this.caps,
|
||||
required this.token,
|
||||
this.fileInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [viewUrl, caps];
|
||||
List<Object> get props => [
|
||||
viewUrl,
|
||||
caps,
|
||||
token,
|
||||
if (fileInfo != null) fileInfo!,
|
||||
];
|
||||
}
|
||||
|
||||
class DocumentViewerError extends DocumentViewerState {
|
||||
|
||||
@@ -52,9 +52,13 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
||||
return;
|
||||
}
|
||||
if (!session.readOnly) {
|
||||
emit(EditorSessionActive(editUrl: session.editUrl));
|
||||
emit(
|
||||
EditorSessionActive(editUrl: session.editUrl, token: session.token),
|
||||
);
|
||||
} else {
|
||||
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
||||
emit(
|
||||
EditorSessionReadOnly(viewUrl: session.editUrl, token: session.token),
|
||||
);
|
||||
}
|
||||
_expiryTimer = Timer(
|
||||
session.expiresAt.difference(DateTime.now()),
|
||||
|
||||
@@ -13,20 +13,22 @@ class EditorSessionStarting extends EditorSessionState {}
|
||||
|
||||
class EditorSessionActive extends EditorSessionState {
|
||||
final Uri editUrl;
|
||||
final String token;
|
||||
|
||||
const EditorSessionActive({required this.editUrl});
|
||||
const EditorSessionActive({required this.editUrl, required this.token});
|
||||
|
||||
@override
|
||||
List<Object> get props => [editUrl];
|
||||
List<Object> get props => [editUrl, token];
|
||||
}
|
||||
|
||||
class EditorSessionReadOnly extends EditorSessionState {
|
||||
final Uri viewUrl;
|
||||
final String token;
|
||||
|
||||
const EditorSessionReadOnly({required this.viewUrl});
|
||||
const EditorSessionReadOnly({required this.viewUrl, required this.token});
|
||||
|
||||
@override
|
||||
List<Object> get props => [viewUrl];
|
||||
List<Object> get props => [viewUrl, token];
|
||||
}
|
||||
|
||||
class EditorSessionFailed extends EditorSessionState {
|
||||
|
||||
@@ -123,24 +123,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
event.parentPath,
|
||||
event.folderName,
|
||||
);
|
||||
// Add the new folder to local state if in current directory
|
||||
if (event.parentPath == _currentPath) {
|
||||
final newFolder = FileItem(
|
||||
name: event.folderName,
|
||||
path: '${event.parentPath}/${event.folderName}',
|
||||
type: FileType.folder,
|
||||
size: 0,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
_currentFiles.add(newFolder);
|
||||
_currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending);
|
||||
_filteredFiles = _currentFiles
|
||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
||||
.toList();
|
||||
_emitLoadedState(emit);
|
||||
} else {
|
||||
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||
}
|
||||
// Reload directory to get the folder with proper ID from backend
|
||||
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||
} catch (e) {
|
||||
emit(DirectoryError(_getErrorMessage(e)));
|
||||
}
|
||||
@@ -192,7 +176,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
|
||||
try {
|
||||
await _fileService.deleteFile(event.orgId, event.path);
|
||||
_currentFiles.removeWhere((f) => f.path == event.path);
|
||||
// Create new list to trigger Equatable change detection
|
||||
_currentFiles = _currentFiles.where((f) => f.path != event.path).toList();
|
||||
_filteredFiles = _currentFiles
|
||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
||||
.toList();
|
||||
@@ -206,9 +191,9 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
ResetFileBrowser event,
|
||||
Emitter<FileBrowserState> emit,
|
||||
) {
|
||||
emit(DirectoryInitial());
|
||||
_currentOrgId = '';
|
||||
_currentPath = '/';
|
||||
_currentFiles = [];
|
||||
_filteredFiles = [];
|
||||
_currentFilter = '';
|
||||
_currentPage = 1;
|
||||
_pageSize = 20;
|
||||
@@ -277,6 +262,12 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
) {
|
||||
final sorted = List<FileItem>.from(files);
|
||||
sorted.sort((a, b) {
|
||||
// Always put folders first, then files
|
||||
if (a.type != b.type) {
|
||||
return a.type == FileType.folder ? -1 : 1;
|
||||
}
|
||||
|
||||
// Within the same type (both folders or both files), sort by the selected criterion
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return isAscending
|
||||
@@ -291,12 +282,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
? a.size.compareTo(b.size)
|
||||
: b.size.compareTo(a.size);
|
||||
case 'type':
|
||||
// Folders before files if ascending, else files before folders
|
||||
int typeCompare = isAscending
|
||||
? a.type.index.compareTo(b.type.index)
|
||||
: b.type.index.compareTo(a.type.index);
|
||||
if (typeCompare != 0) return typeCompare;
|
||||
// Within same type, sort by name
|
||||
// Already handled above (folders vs files)
|
||||
return isAscending
|
||||
? a.name.compareTo(b.name)
|
||||
: b.name.compareTo(a.name);
|
||||
|
||||
@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
|
||||
List<Object> get props => [orgId, parentPath, folderName];
|
||||
}
|
||||
|
||||
class ResetFileBrowser extends FileBrowserEvent {}
|
||||
class ResetFileBrowser extends FileBrowserEvent {
|
||||
final String nextOrgId;
|
||||
|
||||
const ResetFileBrowser(this.nextOrgId);
|
||||
|
||||
@override
|
||||
List<Object> get props => [nextOrgId];
|
||||
}
|
||||
|
||||
class LoadPage extends FileBrowserEvent {
|
||||
final int page;
|
||||
|
||||
@@ -73,10 +73,18 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is OrganizationLoaded) {
|
||||
final selected = currentState.organizations.firstWhere(
|
||||
(org) => org.id == event.orgId,
|
||||
orElse: () => currentState.selectedOrg!,
|
||||
);
|
||||
Organization? selected;
|
||||
|
||||
if (event.orgId.isEmpty) {
|
||||
// Personal workspace - set to null to indicate no org selected
|
||||
selected = null;
|
||||
} else {
|
||||
selected = currentState.organizations.firstWhere(
|
||||
(org) => org.id == event.orgId,
|
||||
orElse: () => currentState.selectedOrg!,
|
||||
);
|
||||
}
|
||||
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
@@ -86,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
);
|
||||
// Reset all dependent blocs
|
||||
permissionBloc.add(PermissionsReset());
|
||||
fileBrowserBloc.add(ResetFileBrowser());
|
||||
fileBrowserBloc.add(ResetFileBrowser(event.orgId));
|
||||
uploadBloc.add(ResetUploads());
|
||||
// Load permissions for the selected org
|
||||
permissionBloc.add(LoadPermissions(event.orgId));
|
||||
@@ -97,59 +105,72 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
CreateOrganization event,
|
||||
Emitter<OrganizationState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is OrganizationLoaded) {
|
||||
final name = event.name.trim();
|
||||
if (name.isEmpty) {
|
||||
final name = event.name.trim();
|
||||
if (name.isEmpty) {
|
||||
// Try to preserve current state if possible
|
||||
if (state is OrganizationLoaded) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
organizations: (state as OrganizationLoaded).organizations,
|
||||
selectedOrg: (state as OrganizationLoaded).selectedOrg,
|
||||
isLoading: false,
|
||||
error: 'Organization name cannot be empty',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (currentState.organizations.any((org) => org.name == name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing organizations list
|
||||
List<Organization> existingOrgs = [];
|
||||
Organization? selectedOrg;
|
||||
|
||||
if (state is OrganizationLoaded) {
|
||||
existingOrgs = (state as OrganizationLoaded).organizations;
|
||||
selectedOrg = (state as OrganizationLoaded).selectedOrg;
|
||||
|
||||
// Check for duplicate name (client-side validation)
|
||||
if (existingOrgs.any((org) => org.name == name)) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
organizations: existingOrgs,
|
||||
selectedOrg: selectedOrg,
|
||||
isLoading: false,
|
||||
error: 'Organization with this name already exists',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: existingOrgs,
|
||||
selectedOrg: selectedOrg,
|
||||
isLoading: true,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final newOrg = await orgApi.createOrganization(name);
|
||||
|
||||
final updatedOrgs = [...existingOrgs, newOrg];
|
||||
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
|
||||
// Reset blocs and load permissions for new org
|
||||
permissionBloc.add(PermissionsReset());
|
||||
fileBrowserBloc.add(ResetFileBrowser(newOrg.id));
|
||||
uploadBloc.add(ResetUploads());
|
||||
permissionBloc.add(LoadPermissions(newOrg.id));
|
||||
} catch (e) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
isLoading: true,
|
||||
organizations: existingOrgs,
|
||||
selectedOrg: selectedOrg,
|
||||
isLoading: false,
|
||||
error: _getErrorMessage(e),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final newOrg = await orgApi.createOrganization(name);
|
||||
final updatedOrgs = [...currentState.organizations, newOrg];
|
||||
emit(
|
||||
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
|
||||
);
|
||||
// Reset blocs and load permissions for new org
|
||||
permissionBloc.add(PermissionsReset());
|
||||
fileBrowserBloc.add(ResetFileBrowser());
|
||||
uploadBloc.add(ResetUploads());
|
||||
permissionBloc.add(LoadPermissions(newOrg.id));
|
||||
} catch (e) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
isLoading: false,
|
||||
error: _getErrorMessage(e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'permission_event.dart';
|
||||
import 'permission_state.dart';
|
||||
import '../../services/api_client.dart';
|
||||
|
||||
class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
||||
PermissionBloc() : super(PermissionInitial()) {
|
||||
final ApiClient apiClient;
|
||||
|
||||
PermissionBloc(this.apiClient) : super(PermissionInitial()) {
|
||||
on<LoadPermissions>(_onLoadPermissions);
|
||||
on<PermissionsReset>(_onPermissionsReset);
|
||||
}
|
||||
@@ -12,19 +16,36 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
||||
LoadPermissions event,
|
||||
Emitter<PermissionState> emit,
|
||||
) async {
|
||||
if (event.orgId.isEmpty) {
|
||||
// Personal workspace - assume full permissions
|
||||
final capabilities = Capabilities(
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canShare: true,
|
||||
canAdmin: true,
|
||||
canAnnotate: true,
|
||||
canEdit: true,
|
||||
);
|
||||
emit(PermissionLoaded(capabilities));
|
||||
return;
|
||||
}
|
||||
emit(PermissionLoading());
|
||||
// Simulate loading permissions from backend for orgId
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
// Mock capabilities based on orgId
|
||||
final capabilities = Capabilities(
|
||||
canRead: true,
|
||||
canWrite: event.orgId == 'org1', // Only admin for personal
|
||||
canShare: event.orgId == 'org1',
|
||||
canAdmin: event.orgId == 'org1',
|
||||
canAnnotate: true,
|
||||
canEdit: true,
|
||||
);
|
||||
emit(PermissionLoaded(capabilities));
|
||||
try {
|
||||
final response = await apiClient.getRaw(
|
||||
'/orgs/${event.orgId}/permissions',
|
||||
);
|
||||
final capabilities = Capabilities(
|
||||
canRead: response['canRead'] ?? false,
|
||||
canWrite: response['canWrite'] ?? false,
|
||||
canShare: response['canShare'] ?? false,
|
||||
canAdmin: response['canAdmin'] ?? false,
|
||||
canAnnotate: response['canAnnotate'] ?? false,
|
||||
canEdit: response['canEdit'] ?? false,
|
||||
);
|
||||
emit(PermissionLoaded(capabilities));
|
||||
} catch (e) {
|
||||
emit(PermissionDenied(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onPermissionsReset(
|
||||
|
||||
@@ -1,42 +1,104 @@
|
||||
import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'session_event.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||
Timer? _expiryTimer;
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _expiryKey = 'auth_expiry';
|
||||
|
||||
SessionBloc() : super(SessionInitial()) {
|
||||
on<SessionStarted>(_onSessionStarted);
|
||||
on<SessionExpired>(_onSessionExpired);
|
||||
on<SessionRefreshed>(_onSessionRefreshed);
|
||||
on<SessionEnded>(_onSessionEnded);
|
||||
on<SessionRestored>(_onSessionRestored);
|
||||
}
|
||||
|
||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
||||
void _onSessionStarted(
|
||||
SessionStarted event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
final expiresAt = DateTime.now().add(
|
||||
const Duration(minutes: 15),
|
||||
); // Match Go
|
||||
|
||||
// Save token to persistent storage
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, event.token);
|
||||
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
||||
_expiryTimer?.cancel();
|
||||
_clearStoredSession();
|
||||
emit(SessionExpiredState());
|
||||
}
|
||||
|
||||
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
||||
void _onSessionRefreshed(
|
||||
SessionRefreshed event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
||||
|
||||
// Update stored token
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, event.newToken);
|
||||
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||
|
||||
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
||||
_expiryTimer?.cancel();
|
||||
_clearStoredSession();
|
||||
emit(SessionInitial());
|
||||
}
|
||||
|
||||
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
|
||||
final expiresAt = event.expiresAt;
|
||||
final now = DateTime.now();
|
||||
|
||||
// Check if token is still valid
|
||||
if (expiresAt.isAfter(now)) {
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
} else {
|
||||
// Token expired, clear it
|
||||
_clearStoredSession();
|
||||
emit(SessionInitial());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearStoredSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiryKey);
|
||||
}
|
||||
|
||||
static Future<void> restoreSession(SessionBloc bloc) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(_tokenKey);
|
||||
final expiryStr = prefs.getString(_expiryKey);
|
||||
|
||||
if (token != null && expiryStr != null) {
|
||||
try {
|
||||
final expiresAt = DateTime.parse(expiryStr);
|
||||
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
|
||||
} catch (e) {
|
||||
// Invalid stored data, clear it
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startExpiryTimer(DateTime expiresAt) {
|
||||
_expiryTimer?.cancel();
|
||||
final duration = expiresAt.difference(DateTime.now());
|
||||
|
||||
@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
|
||||
}
|
||||
|
||||
class SessionEnded extends SessionEvent {}
|
||||
|
||||
class SessionRestored extends SessionEvent {
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const SessionRestored({required this.token, required this.expiresAt});
|
||||
|
||||
@override
|
||||
List<Object> get props => [token, expiresAt];
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
|
||||
try {
|
||||
// Simulate upload
|
||||
await _fileRepository.uploadFile(event.orgId, file);
|
||||
|
||||
add(UploadCompleted(file));
|
||||
} catch (e) {
|
||||
add(UploadFailed(fileName: file.name, error: e.toString()));
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import 'package:b0esche_cloud/services/api_client.dart';
|
||||
import 'services/api_client.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'blocs/session/session_bloc.dart';
|
||||
import 'repositories/auth_repository.dart';
|
||||
import 'repositories/file_repository.dart';
|
||||
import 'repositories/mock_auth_repository.dart';
|
||||
import 'repositories/mock_file_repository.dart';
|
||||
import 'repositories/http_auth_repository.dart';
|
||||
import 'repositories/http_file_repository.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/file_service.dart';
|
||||
import 'viewmodels/login_view_model.dart';
|
||||
import 'viewmodels/file_explorer_view_model.dart';
|
||||
import 'services/org_api.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
void configureDependencies() {
|
||||
// Register repositories
|
||||
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
|
||||
getIt.registerSingleton<FileRepository>(MockFileRepository());
|
||||
void configureDependencies(SessionBloc sessionBloc) {
|
||||
// Register ApiClient first
|
||||
final apiClient = ApiClient(sessionBloc);
|
||||
getIt.registerSingleton<ApiClient>(apiClient);
|
||||
|
||||
// Register repositories (HTTP-backed)
|
||||
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
|
||||
getIt.registerSingleton<FileRepository>(
|
||||
HttpFileRepository(FileService(apiClient)),
|
||||
);
|
||||
|
||||
// Register services
|
||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||
|
||||
// Register viewmodels
|
||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||
getIt.registerSingleton<FileExplorerViewModel>(
|
||||
FileExplorerViewModel(getIt<FileService>()),
|
||||
);
|
||||
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'blocs/auth/auth_bloc.dart';
|
||||
import 'blocs/auth/auth_event.dart';
|
||||
import 'blocs/session/session_bloc.dart';
|
||||
import 'blocs/activity/activity_bloc.dart';
|
||||
import 'services/api_client.dart';
|
||||
@@ -10,11 +11,20 @@ import 'pages/home_page.dart';
|
||||
import 'pages/file_explorer.dart';
|
||||
import 'pages/document_viewer.dart';
|
||||
import 'pages/editor_page.dart';
|
||||
import 'pages/join_page.dart';
|
||||
import 'pages/login_page.dart';
|
||||
import 'blocs/session/session_state.dart';
|
||||
import 'pages/public_file_viewer.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'injection.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
final GoRouter _router = GoRouter(
|
||||
initialLocation: kIsWeb ? Uri.base.path : '/',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => const HomePage()),
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
GoRoute(
|
||||
path: '/viewer/:orgId/:fileId',
|
||||
builder: (context, state) => DocumentViewer(
|
||||
@@ -34,6 +44,16 @@ final GoRouter _router = GoRouter(
|
||||
builder: (context, state) =>
|
||||
FileExplorer(orgId: state.pathParameters['orgId']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/join',
|
||||
builder: (context, state) =>
|
||||
JoinPage(token: state.uri.queryParameters['token'] ?? ''),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/share/:token',
|
||||
builder: (context, state) =>
|
||||
PublicFileViewer(token: state.pathParameters['token']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -41,29 +61,93 @@ void main() {
|
||||
runApp(const MainApp());
|
||||
}
|
||||
|
||||
class MainApp extends StatelessWidget {
|
||||
class MainApp extends StatefulWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
State<MainApp> createState() => _MainAppState();
|
||||
}
|
||||
|
||||
class _MainAppState extends State<MainApp> {
|
||||
final _sessionBloc = SessionBloc();
|
||||
late final AuthBloc _authBloc;
|
||||
late final Future<void> _restoreFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Configure DI first
|
||||
configureDependencies(_sessionBloc);
|
||||
|
||||
// Create AuthBloc first
|
||||
_authBloc = AuthBloc(
|
||||
apiClient: ApiClient(_sessionBloc),
|
||||
sessionBloc: _sessionBloc,
|
||||
);
|
||||
|
||||
// Restore session and then check auth
|
||||
_restoreFuture = SessionBloc.restoreSession(_sessionBloc).then((_) {
|
||||
// After session is restored, check if we should auto-authenticate
|
||||
if (mounted) {
|
||||
_authBloc.add(const CheckAuthRequested());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
||||
BlocProvider<AuthBloc>(
|
||||
create: (context) => AuthBloc(
|
||||
apiClient: ApiClient(context.read<SessionBloc>()),
|
||||
sessionBloc: context.read<SessionBloc>(),
|
||||
),
|
||||
),
|
||||
BlocProvider<SessionBloc>.value(value: _sessionBloc),
|
||||
BlocProvider<AuthBloc>.value(value: _authBloc),
|
||||
BlocProvider<ActivityBloc>(
|
||||
create: (context) =>
|
||||
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
||||
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
theme: AppTheme.darkTheme,
|
||||
child: FutureBuilder<void>(
|
||||
future: _restoreFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.darkTheme,
|
||||
home: const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
theme: AppTheme.darkTheme,
|
||||
builder: (context, child) {
|
||||
return BlocListener<SessionBloc, SessionState>(
|
||||
listener: (context, state) {
|
||||
if (state is SessionExpiredState) {
|
||||
final currentLocation = GoRouterState.of(
|
||||
context,
|
||||
).uri.toString();
|
||||
context.go('/login?redirect=$currentLocation');
|
||||
}
|
||||
},
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.close();
|
||||
_sessionBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class FileInfo extends Equatable {
|
||||
final String name;
|
||||
final int size;
|
||||
final DateTime? lastModified;
|
||||
final String? modifiedByName;
|
||||
|
||||
const FileInfo({
|
||||
required this.name,
|
||||
required this.size,
|
||||
this.lastModified,
|
||||
this.modifiedByName,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, size, lastModified, modifiedByName];
|
||||
|
||||
factory FileInfo.fromJson(Map<String, dynamic> json) {
|
||||
return FileInfo(
|
||||
name: json['name'] ?? '',
|
||||
size: json['size'] ?? 0,
|
||||
lastModified: json['lastModified'] != null
|
||||
? DateTime.tryParse(json['lastModified'])
|
||||
: null,
|
||||
modifiedByName: json['modifiedByName'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentCapabilities extends Equatable {
|
||||
final bool canEdit;
|
||||
final bool canAnnotate;
|
||||
final bool isPdf;
|
||||
final String mimeType;
|
||||
|
||||
const DocumentCapabilities({
|
||||
required this.canEdit,
|
||||
required this.canAnnotate,
|
||||
required this.isPdf,
|
||||
required this.mimeType,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [canEdit, canAnnotate, isPdf];
|
||||
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
|
||||
|
||||
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentCapabilities(
|
||||
canEdit: json['canEdit'],
|
||||
canAnnotate: json['canAnnotate'],
|
||||
isPdf: json['isPdf'],
|
||||
mimeType: json['mimeType'] ?? 'application/octet-stream',
|
||||
);
|
||||
}
|
||||
|
||||
bool get isImage => mimeType.startsWith('image/');
|
||||
bool get isText => mimeType.startsWith('text/');
|
||||
bool get isOffice =>
|
||||
mimeType.contains('word') ||
|
||||
mimeType.contains('spreadsheet') ||
|
||||
mimeType.contains('presentation');
|
||||
bool get isVideo => mimeType.startsWith('video/');
|
||||
bool get isAudio => mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
@@ -2,21 +2,24 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
class EditorSession extends Equatable {
|
||||
final Uri editUrl;
|
||||
final String token;
|
||||
final bool readOnly;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const EditorSession({
|
||||
required this.editUrl,
|
||||
required this.token,
|
||||
required this.readOnly,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [editUrl, readOnly, expiresAt];
|
||||
List<Object?> get props => [editUrl, token, readOnly, expiresAt];
|
||||
|
||||
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
||||
return EditorSession(
|
||||
editUrl: Uri.parse(json['editUrl']),
|
||||
token: json['token'] ?? '',
|
||||
readOnly: json['readOnly'],
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
);
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum FileType { folder, file }
|
||||
|
||||
class FileItem extends Equatable {
|
||||
final String? id;
|
||||
final String name;
|
||||
final String path;
|
||||
final FileType type;
|
||||
final int size; // in bytes, 0 for folders
|
||||
final DateTime lastModified;
|
||||
final String? localPath; // optional local file path for uploads
|
||||
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||
|
||||
const FileItem({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.type,
|
||||
this.size = 0,
|
||||
required this.lastModified,
|
||||
this.localPath,
|
||||
this.bytes,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, path, type, size, lastModified];
|
||||
List<Object?> get props => [id, name, path, type, size, lastModified];
|
||||
|
||||
FileItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? path,
|
||||
FileType? type,
|
||||
@@ -28,6 +36,7 @@ class FileItem extends Equatable {
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
return FileItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
path: path ?? this.path,
|
||||
type: type ?? this.type,
|
||||
|
||||
96
b0esche_cloud/lib/models/organization.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'user.dart';
|
||||
|
||||
class Member {
|
||||
final String userId;
|
||||
final String orgId;
|
||||
final String role;
|
||||
final DateTime createdAt;
|
||||
final User user;
|
||||
|
||||
const Member({
|
||||
required this.userId,
|
||||
required this.orgId,
|
||||
required this.role,
|
||||
required this.createdAt,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory Member.fromJson(Map<String, dynamic> json) {
|
||||
return Member(
|
||||
userId: json['UserID'] ?? json['userId'],
|
||||
orgId: json['OrgID'] ?? json['orgId'],
|
||||
role: json['Role'] ?? json['role'],
|
||||
createdAt: DateTime.parse(json['CreatedAt'] ?? json['createdAt']),
|
||||
user: User.fromJson(json['user']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Invitation {
|
||||
final String id;
|
||||
final String orgId;
|
||||
final String invitedBy;
|
||||
final String username;
|
||||
final String role;
|
||||
final DateTime createdAt;
|
||||
final DateTime expiresAt;
|
||||
final DateTime? acceptedAt;
|
||||
|
||||
const Invitation({
|
||||
required this.id,
|
||||
required this.orgId,
|
||||
required this.invitedBy,
|
||||
required this.username,
|
||||
required this.role,
|
||||
required this.createdAt,
|
||||
required this.expiresAt,
|
||||
this.acceptedAt,
|
||||
});
|
||||
|
||||
factory Invitation.fromJson(Map<String, dynamic> json) {
|
||||
return Invitation(
|
||||
id: json['id'],
|
||||
orgId: json['orgId'],
|
||||
invitedBy: json['invitedBy'],
|
||||
username: json['username'],
|
||||
role: json['role'],
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
acceptedAt: json['acceptedAt'] != null
|
||||
? DateTime.parse(json['acceptedAt'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JoinRequest {
|
||||
final String id;
|
||||
final String orgId;
|
||||
final String userId;
|
||||
final String? inviteToken;
|
||||
final DateTime requestedAt;
|
||||
final String status;
|
||||
final User user;
|
||||
|
||||
const JoinRequest({
|
||||
required this.id,
|
||||
required this.orgId,
|
||||
required this.userId,
|
||||
this.inviteToken,
|
||||
required this.requestedAt,
|
||||
required this.status,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory JoinRequest.fromJson(Map<String, dynamic> json) {
|
||||
return JoinRequest(
|
||||
id: json['ID'] ?? json['id'],
|
||||
orgId: json['OrgID'] ?? json['orgId'],
|
||||
userId: json['UserID'] ?? json['userId'],
|
||||
inviteToken: json['InviteToken'] ?? json['inviteToken'],
|
||||
requestedAt: DateTime.parse(json['RequestedAt'] ?? json['requestedAt']),
|
||||
status: json['Status'] ?? json['status'],
|
||||
user: User.fromJson(json['user']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ class User extends Equatable {
|
||||
final String username;
|
||||
final String email;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final DateTime createdAt;
|
||||
final DateTime? lastLoginAt;
|
||||
|
||||
@@ -13,6 +14,7 @@ class User extends Equatable {
|
||||
required this.username,
|
||||
required this.email,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
required this.createdAt,
|
||||
this.lastLoginAt,
|
||||
});
|
||||
@@ -23,6 +25,7 @@ class User extends Equatable {
|
||||
username,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
createdAt,
|
||||
lastLoginAt,
|
||||
];
|
||||
@@ -32,6 +35,7 @@ class User extends Equatable {
|
||||
String? username,
|
||||
String? email,
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastLoginAt,
|
||||
}) {
|
||||
@@ -40,6 +44,7 @@ class User extends Equatable {
|
||||
username: username ?? this.username,
|
||||
email: email ?? this.email,
|
||||
displayName: displayName ?? this.displayName,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||
);
|
||||
@@ -51,6 +56,7 @@ class User extends Equatable {
|
||||
username: json['username'] as String,
|
||||
email: json['email'] as String,
|
||||
displayName: json['displayName'] as String?,
|
||||
avatarUrl: json['avatarUrl'] as String?,
|
||||
createdAt: DateTime.parse(
|
||||
json['createdAt'] as String? ?? DateTime.now().toIso8601String(),
|
||||
),
|
||||
@@ -66,6 +72,7 @@ class User extends Equatable {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'displayName': displayName,
|
||||
'avatarUrl': avatarUrl,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'lastLoginAt': lastLoginAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
@@ -6,16 +6,24 @@ class ViewerSession extends Equatable {
|
||||
final DocumentCapabilities capabilities;
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final FileInfo? fileInfo;
|
||||
|
||||
const ViewerSession({
|
||||
required this.viewUrl,
|
||||
required this.capabilities,
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
this.fileInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewUrl, capabilities, token, expiresAt];
|
||||
List<Object?> get props => [
|
||||
viewUrl,
|
||||
capabilities,
|
||||
token,
|
||||
expiresAt,
|
||||
fileInfo,
|
||||
];
|
||||
|
||||
factory ViewerSession.fromJson(Map<String, dynamic> json) {
|
||||
return ViewerSession(
|
||||
@@ -23,6 +31,9 @@ class ViewerSession extends Equatable {
|
||||
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
|
||||
token: json['token'],
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
fileInfo: json['fileInfo'] != null
|
||||
? FileInfo.fromJson(json['fileInfo'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,161 @@ import '../services/file_service.dart';
|
||||
import '../injection.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// Modal version for overlay display
|
||||
class EditorPageModal extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const EditorPageModal({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EditorPageModal> createState() => _EditorPageModalState();
|
||||
}
|
||||
|
||||
class _EditorPageModalState extends State<EditorPageModal> {
|
||||
late EditorSessionBloc _editorBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _editorBloc,
|
||||
child: Column(
|
||||
children: [
|
||||
// Custom AppBar
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Document Editor',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
onPressed: () {
|
||||
_editorBloc.add(EditorSessionEnded());
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Editor content
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
|
||||
builder: (context, state) {
|
||||
if (state is EditorSessionStarting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state is EditorSessionFailed) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionActive) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Collabora Editor Active\\n(URL: ${state.editUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionReadOnly) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Read Only Mode\\n(URL: ${state.viewUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionExpired) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Editing session expired.',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(
|
||||
orgId: widget.orgId,
|
||||
fileId: widget.fileId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reopen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No editor session',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_editorBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Original page version (for routing if needed)
|
||||
class EditorPage extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
137
b0esche_cloud/lib/pages/join_page.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../blocs/organization/organization_state.dart';
|
||||
import '../services/org_api.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class JoinPage extends StatefulWidget {
|
||||
final String token;
|
||||
|
||||
const JoinPage({super.key, required this.token});
|
||||
|
||||
@override
|
||||
State<JoinPage> createState() => _JoinPageState();
|
||||
}
|
||||
|
||||
class _JoinPageState extends State<JoinPage> {
|
||||
Organization? _org;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
bool _isJoining = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchOrg();
|
||||
}
|
||||
|
||||
Future<void> _fetchOrg() async {
|
||||
if (widget.token.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Invalid invite link';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Assume we have a method to get org by token
|
||||
// For now, since not implemented, use ApiClient directly
|
||||
final apiClient = GetIt.I<ApiClient>();
|
||||
final result = await apiClient.get(
|
||||
'/join?token=${widget.token}',
|
||||
fromJson: (data) => Organization.fromJson(data),
|
||||
);
|
||||
setState(() {
|
||||
_org = result;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Invalid or expired invite link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joinOrg() async {
|
||||
if (_org == null) return;
|
||||
|
||||
setState(() => _isJoining = true);
|
||||
try {
|
||||
final orgApi = GetIt.I<OrgApi>();
|
||||
await orgApi.createJoinRequest(_org!.id, inviteToken: widget.token);
|
||||
// Navigate to home or show success
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Join request sent successfully')),
|
||||
);
|
||||
Navigator.of(context).pushReplacementNamed('/'); // Assuming home is /
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to join: $e')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isJoining = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('Join Organization'),
|
||||
backgroundColor: AppTheme.secondaryBackground,
|
||||
),
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: AppTheme.errorColor)),
|
||||
const SizedBox(height: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Go Back'),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _org != null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Join ${_org!.name}?',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'You have been invited to join this organization.',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_isJoining
|
||||
? const CircularProgressIndicator()
|
||||
: ModernGlassButton(
|
||||
onPressed: _joinOrg,
|
||||
child: const Text('Join Organization'),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Text('Organization not found'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
import '../blocs/auth/auth_event.dart';
|
||||
import '../blocs/auth/auth_state.dart';
|
||||
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
|
||||
|
||||
class LoginForm extends StatefulWidget {
|
||||
final ValueChanged<bool>? onSignupModeChanged;
|
||||
final ValueChanged<bool>? onPasswordModeChanged;
|
||||
|
||||
const LoginForm({super.key, this.onSignupModeChanged});
|
||||
const LoginForm({
|
||||
super.key,
|
||||
this.onSignupModeChanged,
|
||||
this.onPasswordModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoginForm> createState() => _LoginFormState();
|
||||
@@ -26,6 +32,12 @@ class _LoginFormState extends State<LoginForm> {
|
||||
bool _usePasskey = true;
|
||||
bool _isSignup = false;
|
||||
|
||||
// UI error state for inline validation
|
||||
String? _usernameErrorText;
|
||||
String? _passwordErrorText;
|
||||
bool _usernameHasError = false;
|
||||
bool _passwordHasError = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
@@ -34,10 +46,10 @@ class _LoginFormState extends State<LoginForm> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _generateRandomHex(int bytes) {
|
||||
String _generateRandomBase64(int bytes) {
|
||||
final random = Random();
|
||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
||||
return base64.encode(values);
|
||||
}
|
||||
|
||||
Future<void> _handleAuthentication(
|
||||
@@ -47,7 +59,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
try {
|
||||
final credentialId = state.credentialIds.isNotEmpty
|
||||
? state.credentialIds.first
|
||||
: _generateRandomHex(64);
|
||||
: _generateRandomBase64(64);
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -55,10 +67,10 @@ class _LoginFormState extends State<LoginForm> {
|
||||
username: _usernameController.text,
|
||||
challenge: state.challenge,
|
||||
credentialId: credentialId,
|
||||
authenticatorData: _generateRandomHex(37),
|
||||
authenticatorData: _generateRandomBase64(37),
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
signature: _generateRandomHex(128),
|
||||
signature: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -76,8 +88,8 @@ class _LoginFormState extends State<LoginForm> {
|
||||
RegistrationChallengeReceived state,
|
||||
) async {
|
||||
try {
|
||||
final credentialId = _generateRandomHex(64);
|
||||
final publicKey = _generateRandomHex(91);
|
||||
final credentialId = _generateRandomBase64(64);
|
||||
final publicKey = _generateRandomBase64(91);
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -88,7 +100,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
publicKey: publicKey,
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
attestationObject: _generateRandomHex(128),
|
||||
attestationObject: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -106,6 +118,12 @@ class _LoginFormState extends State<LoginForm> {
|
||||
_passwordController.clear();
|
||||
_displayNameController.clear();
|
||||
_usePasskey = true;
|
||||
|
||||
// Clear inline error state
|
||||
_usernameHasError = false;
|
||||
_passwordHasError = false;
|
||||
_usernameErrorText = null;
|
||||
_passwordErrorText = null;
|
||||
}
|
||||
|
||||
void _setSignupMode(bool isSignup) {
|
||||
@@ -118,71 +136,70 @@ class _LoginFormState extends State<LoginForm> {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthFailure) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.error)));
|
||||
// Handle specific credential errors inline
|
||||
if (state.code == 'INVALID_PASSWORD' ||
|
||||
state.error.toLowerCase().contains('incorrect')) {
|
||||
setState(() {
|
||||
_passwordHasError = true;
|
||||
_passwordErrorText = 'incorrect password';
|
||||
_passwordController.clear();
|
||||
// clear username error if any
|
||||
_usernameHasError = false;
|
||||
_usernameErrorText = null;
|
||||
});
|
||||
} else if (state.code == 'INVALID_CREDENTIALS' ||
|
||||
state.error.toLowerCase().contains('invalid credentials')) {
|
||||
setState(() {
|
||||
// Border both fields red but show the helper text only under the password field
|
||||
_usernameHasError = true;
|
||||
_passwordHasError = true;
|
||||
_usernameErrorText = null;
|
||||
_passwordErrorText = 'invalid credentials';
|
||||
_usernameController.clear();
|
||||
_passwordController.clear();
|
||||
});
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.error)));
|
||||
}
|
||||
} else if (state is AuthenticationChallengeReceived) {
|
||||
_handleAuthentication(context, state);
|
||||
} else if (state is RegistrationChallengeReceived) {
|
||||
_handleRegistration(context, state);
|
||||
} else if (state is AuthAuthenticated) {
|
||||
context.read<SessionBloc>().add(SessionStarted(state.token));
|
||||
context.go('/');
|
||||
final redirect = GoRouterState.of(
|
||||
context,
|
||||
).uri.queryParameters['redirect'];
|
||||
context.go(redirect ?? '/');
|
||||
}
|
||||
},
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey<bool>(_isSignup),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_isSignup ? 'create account' : 'sign in',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey('${_isSignup}_$_usePasskey'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_isSignup ? 'Create Account' : 'Sign In',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.person_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
@@ -190,109 +207,161 @@ class _LoginFormState extends State<LoginForm> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
color: _usernameHasError
|
||||
? Colors.red
|
||||
: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _displayNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
onChanged: (_) {
|
||||
if (_usernameHasError || _usernameErrorText != null) {
|
||||
setState(() {
|
||||
_usernameHasError = false;
|
||||
_usernameErrorText = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'display name (optional)',
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
Icons.person_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_isSignup)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ModernGlassButton(
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: () {
|
||||
if (_usernameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Username is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
),
|
||||
if (_usernameErrorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 8),
|
||||
child: Text(
|
||||
_usernameErrorText!,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _passwordHasError
|
||||
? Colors.red
|
||||
: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
onChanged: (_) {
|
||||
if (_passwordHasError ||
|
||||
_passwordErrorText != null) {
|
||||
setState(() {
|
||||
_passwordHasError = false;
|
||||
_passwordErrorText = null;
|
||||
});
|
||||
}
|
||||
if (_isSignup) {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (_passwordErrorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 8),
|
||||
child: Text(
|
||||
_passwordErrorText!,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _displayNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'display name (optional)',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_isSignup)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ModernGlassButton(
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: () {
|
||||
if (_usernameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password is required'),
|
||||
content: Text('Username is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
SignupStarted(
|
||||
username: _usernameController.text,
|
||||
email: _usernameController.text,
|
||||
displayName: _displayNameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_usePasskey) {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
username: _usernameController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_isSignup) {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -302,91 +371,125 @@ class _LoginFormState extends State<LoginForm> {
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
PasswordLoginRequested(
|
||||
SignupStarted(
|
||||
username: _usernameController.text,
|
||||
email: _usernameController.text,
|
||||
displayName: _displayNameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_usePasskey) {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
username: _usernameController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
PasswordLoginRequested(
|
||||
username: _usernameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||
);
|
||||
},
|
||||
},
|
||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'already have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(false);
|
||||
},
|
||||
child: Text(
|
||||
'sign in',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'already have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(false);
|
||||
},
|
||||
child: Text(
|
||||
'sign in',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
setState(() => _usePasskey = !_usePasskey),
|
||||
child: Text(
|
||||
_usePasskey ? 'use password' : 'use passkey',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_usePasskey = !_usePasskey;
|
||||
// Clear password errors when switching modes
|
||||
_passwordHasError = false;
|
||||
_passwordErrorText = null;
|
||||
});
|
||||
widget.onPasswordModeChanged?.call(
|
||||
!_usePasskey,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
_usePasskey ? 'use password' : 'use passkey',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'don\'t have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(true);
|
||||
},
|
||||
child: Text(
|
||||
'create one',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'don\'t have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(true);
|
||||
},
|
||||
child: Text(
|
||||
'create one',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
27
b0esche_cloud/lib/pages/login_page.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import './login_form.dart';
|
||||
|
||||
class LoginPage extends StatelessWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF1a1a2e), Color(0xFF16213e), Color(0xFF0f3460)],
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: LoginForm(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
433
b0esche_cloud/lib/pages/public_file_viewer.dart
Normal file
@@ -0,0 +1,433 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'dart:ui_web' as ui_web;
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../theme/app_theme.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../injection.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import '../widgets/file_viewer_dispatch.dart';
|
||||
|
||||
class PublicFileViewer extends StatefulWidget {
|
||||
final String token;
|
||||
|
||||
const PublicFileViewer({super.key, required this.token});
|
||||
|
||||
@override
|
||||
State<PublicFileViewer> createState() => _PublicFileViewerState();
|
||||
}
|
||||
|
||||
class _PublicFileViewerState extends State<PublicFileViewer> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
Map<String, dynamic>? _fileData;
|
||||
VideoPlayerController? _videoController;
|
||||
String? _videoViewType;
|
||||
String? _docxViewType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFileData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadFileData() async {
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final response = await apiClient.getRaw('/public/share/${widget.token}');
|
||||
|
||||
setState(() {
|
||||
_fileData = response;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Initialize video player if it's a video file
|
||||
if (_isVideoFile()) {
|
||||
await _initializeVideoPlayer();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'This link is invalid or has expired.';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeVideoPlayer() async {
|
||||
if (!kIsWeb) {
|
||||
// For mobile, use VideoPlayerController
|
||||
final url = _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
|
||||
if (url != null) {
|
||||
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
|
||||
await _videoController!.initialize();
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
// For web, use HTML video element
|
||||
final url = _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
|
||||
if (url != null) {
|
||||
_videoViewType = 'public-video-viewer-${widget.token.hashCode}';
|
||||
_registerVideoViewFactory(url);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _registerVideoViewFactory(String videoUrl) {
|
||||
ui_web.platformViewRegistry.registerViewFactory(_videoViewType!, (
|
||||
int viewId,
|
||||
) {
|
||||
final videoElement = web.HTMLVideoElement()
|
||||
..src = videoUrl
|
||||
..controls = true
|
||||
..autoplay = false
|
||||
..crossOrigin = 'anonymous'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.objectFit = 'contain';
|
||||
|
||||
videoElement.onError.listen((event) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error =
|
||||
'Video format not supported or could not be loaded. Please download the file.';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return videoElement;
|
||||
});
|
||||
}
|
||||
|
||||
String? _getViewUrl() {
|
||||
return _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
|
||||
}
|
||||
|
||||
String? _getDownloadUrl() {
|
||||
return _fileData?['downloadUrl'];
|
||||
}
|
||||
|
||||
bool _isVideoFile() {
|
||||
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
|
||||
return mimeType.toString().startsWith('video/');
|
||||
}
|
||||
|
||||
bool _isAudioFile() {
|
||||
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
|
||||
return mimeType.toString().startsWith('audio/');
|
||||
}
|
||||
|
||||
bool _isImageFile() {
|
||||
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
|
||||
return mimeType.toString().startsWith('image/');
|
||||
}
|
||||
|
||||
bool _isPdfFile() {
|
||||
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
|
||||
return mimeType == 'application/pdf' ||
|
||||
(_fileData?['capabilities']?['isPdf'] ?? false);
|
||||
}
|
||||
|
||||
bool _isDocumentFile() {
|
||||
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
|
||||
return mimeType ==
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
mimeType == 'application/msword' ||
|
||||
mimeType.toString().contains('document');
|
||||
}
|
||||
|
||||
void _downloadFile() {
|
||||
final downloadUrl = _getDownloadUrl();
|
||||
if (downloadUrl != null) {
|
||||
// Trigger download directly in browser
|
||||
final anchor = web.HTMLAnchorElement()
|
||||
..href = downloadUrl
|
||||
..download = _fileData!['fileName'] ?? 'download';
|
||||
anchor.click();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFilePreview() {
|
||||
final viewUrl = _getViewUrl();
|
||||
if (viewUrl == null) return const SizedBox();
|
||||
|
||||
if (_isPdfFile()) {
|
||||
return Expanded(
|
||||
child: FileViewerDispatch.buildFileViewer(
|
||||
context,
|
||||
viewUrl,
|
||||
_fileData?['capabilities']?['mimeType'],
|
||||
fileName: _fileData!['fileName'],
|
||||
viewerId: 'public-pdf-${widget.token.hashCode}',
|
||||
),
|
||||
);
|
||||
} else if (_isVideoFile()) {
|
||||
if (kIsWeb && _videoViewType != null) {
|
||||
// Use HTML video element for web
|
||||
return Expanded(
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: HtmlElementView(viewType: _videoViewType!),
|
||||
),
|
||||
);
|
||||
} else if (!kIsWeb && _videoController != null) {
|
||||
// Use VideoPlayer for mobile
|
||||
return Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: _videoController!.value.aspectRatio,
|
||||
child: VideoPlayer(_videoController!),
|
||||
),
|
||||
);
|
||||
} else if (_error != null) {
|
||||
return Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (_isAudioFile()) {
|
||||
return FileViewerDispatch.buildFileViewer(
|
||||
context,
|
||||
viewUrl,
|
||||
_fileData?['capabilities']?['mimeType'],
|
||||
fileName: _fileData!['fileName'],
|
||||
viewerId: 'public-audio-${widget.token.hashCode}',
|
||||
);
|
||||
} else if (_isImageFile()) {
|
||||
return Expanded(
|
||||
child: FileViewerDispatch.buildFileViewer(
|
||||
context,
|
||||
viewUrl,
|
||||
_fileData?['capabilities']?['mimeType'],
|
||||
fileName: _fileData!['fileName'],
|
||||
viewerId: 'public-image-${widget.token.hashCode}',
|
||||
),
|
||||
);
|
||||
} else if (_isDocumentFile()) {
|
||||
if (kIsWeb) {
|
||||
// Use Collabora viewer for web
|
||||
_docxViewType ??= 'public-docx-viewer-${widget.token.hashCode}';
|
||||
ui_web.platformViewRegistry.registerViewFactory(_docxViewType!, (
|
||||
int viewId,
|
||||
) {
|
||||
final iframeElement = web.HTMLIFrameElement()
|
||||
..src = viewUrl
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.border = 'none';
|
||||
|
||||
iframeElement.onError.listen((event) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error =
|
||||
'Document could not be loaded. Please download the file.';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return iframeElement;
|
||||
});
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
child: HtmlElementView(viewType: _docxViewType!),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 80,
|
||||
color: AppTheme.primaryText.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Document Preview',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This document type requires download to view',
|
||||
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file,
|
||||
size: 80,
|
||||
color: AppTheme.primaryText.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'File Preview',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This file type requires download to view',
|
||||
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
elevation: 0,
|
||||
leading: _fileData != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 6, top: 6, bottom: 6),
|
||||
child: ModernGlassButton(
|
||||
onPressed: _downloadFile,
|
||||
padding: EdgeInsets.zero,
|
||||
showShadows: false,
|
||||
child: SizedBox(
|
||||
width: 104,
|
||||
child: const Center(child: Icon(Icons.download, size: 26)),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: Text(
|
||||
_fileData?['fileName'] ?? 'Shared File',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
style: ButtonStyle(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.pressed)) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||
),
|
||||
)
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Card(
|
||||
color: AppTheme.primaryBackground,
|
||||
elevation: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.link_off, size: 64, color: Colors.red[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: _fileData != null
|
||||
? Column(
|
||||
children: [
|
||||
// File content
|
||||
Expanded(child: _buildFilePreview()),
|
||||
// Video controls (if video)
|
||||
if (_isVideoFile() && _videoController != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ModernGlassButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_videoController!.value.isPlaying
|
||||
? _videoController!.pause()
|
||||
: _videoController!.play();
|
||||
});
|
||||
},
|
||||
child: Icon(
|
||||
_videoController!.value.isPlaying
|
||||
? Icons.pause
|
||||
: Icons.play_arrow,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
import '../blocs/auth/auth_event.dart';
|
||||
import '../blocs/auth/auth_state.dart';
|
||||
@@ -28,10 +29,10 @@ class _SignupFormState extends State<SignupForm> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _generateRandomHex(int bytes) {
|
||||
String _generateRandomBase64(int bytes) {
|
||||
final random = Random();
|
||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
||||
return base64.encode(values);
|
||||
}
|
||||
|
||||
Future<void> _handleRegistration(
|
||||
@@ -41,8 +42,8 @@ class _SignupFormState extends State<SignupForm> {
|
||||
try {
|
||||
// Simulate WebAuthn registration by generating fake credential data
|
||||
// In a real implementation, this would call native WebAuthn APIs
|
||||
final credentialId = _generateRandomHex(64);
|
||||
final publicKey = _generateRandomHex(91); // EC2 public key size
|
||||
final credentialId = _generateRandomBase64(64);
|
||||
final publicKey = _generateRandomBase64(91); // EC2 public key size
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -53,7 +54,7 @@ class _SignupFormState extends State<SignupForm> {
|
||||
publicKey: publicKey,
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
attestationObject: _generateRandomHex(128),
|
||||
attestationObject: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
101
b0esche_cloud/lib/pages/video_viewer.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui_web' as ui_web; // <-- new
|
||||
import 'package:web/web.dart' as web;
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class VideoViewer extends StatefulWidget {
|
||||
final String videoUrl;
|
||||
final String fileName;
|
||||
|
||||
const VideoViewer({
|
||||
super.key,
|
||||
required this.videoUrl,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoViewer> createState() => _VideoViewerState();
|
||||
}
|
||||
|
||||
class _VideoViewerState extends State<VideoViewer> {
|
||||
bool _hasError = false;
|
||||
late String _viewType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewType = 'video-viewer-${widget.videoUrl.hashCode}';
|
||||
_registerViewFactory();
|
||||
}
|
||||
|
||||
void _registerViewFactory() {
|
||||
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
||||
final videoElement = web.HTMLVideoElement()
|
||||
..src = widget.videoUrl
|
||||
..controls = true
|
||||
..autoplay = false
|
||||
..crossOrigin = 'anonymous'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.objectFit = 'contain';
|
||||
|
||||
videoElement.onError.listen((event) {
|
||||
if (mounted) {
|
||||
setState(() => _hasError = true);
|
||||
}
|
||||
});
|
||||
|
||||
return videoElement;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700, maxHeight: 500),
|
||||
child: Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.fileName,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_hasError
|
||||
? const Text(
|
||||
'File type not supported or video could not be loaded',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
)
|
||||
: Expanded(
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: HtmlElementView(viewType: _viewType),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
b0esche_cloud/lib/repositories/http_auth_repository.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import '../models/user.dart';
|
||||
import '../repositories/auth_repository.dart';
|
||||
import '../services/api_client.dart';
|
||||
|
||||
class HttpAuthRepository implements AuthRepository {
|
||||
final ApiClient _apiClient;
|
||||
HttpAuthRepository(this._apiClient);
|
||||
|
||||
@override
|
||||
Future<User> login(String email, String password) async {
|
||||
final res = await _apiClient.post(
|
||||
'/auth/password-login',
|
||||
data: {'username': email, 'password': password},
|
||||
fromJson: (d) {
|
||||
final user = d['user'];
|
||||
return User(
|
||||
id: user['id'].toString(),
|
||||
username: user['username'] ?? user['email'],
|
||||
email: user['email'],
|
||||
createdAt: DateTime.parse(
|
||||
user['createdAt'] ?? DateTime.now().toIso8601String(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User?> getCurrentUser() async {
|
||||
// No refresh endpoint available - rely on SessionBloc for token management
|
||||
// If token is stored and valid, SessionBloc will restore it
|
||||
// If API calls return 401, session will expire automatically
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
// Call backend to revoke session
|
||||
await _apiClient.post('/auth/logout', fromJson: (d) => null);
|
||||
} catch (_) {
|
||||
// Ignore logout errors - clear local session regardless
|
||||
}
|
||||
}
|
||||
}
|
||||
93
b0esche_cloud/lib/repositories/http_file_repository.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import '../models/file_item.dart';
|
||||
import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import '../repositories/file_repository.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
||||
class HttpFileRepository implements FileRepository {
|
||||
final FileService _fileService;
|
||||
HttpFileRepository(this._fileService);
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
return await _fileService.getFiles(orgId, path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FileItem?> getFile(String orgId, String path) async {
|
||||
// Not implemented in API yet; fallback to listing
|
||||
final files = await getFiles(orgId, path);
|
||||
for (final f in files) {
|
||||
if (f.path == path) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
await _fileService.uploadFile(orgId, file);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
await _fileService.deleteFile(orgId, path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createFolder(
|
||||
String orgId,
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
await _fileService.createFolder(orgId, parentPath, folderName);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> moveFile(
|
||||
String orgId,
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) async {
|
||||
await _fileService.moveFile(orgId, sourcePath, targetPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
||||
// Not yet parameterized on API side; fallback to client-side filter
|
||||
final files = await getFiles(orgId, '/');
|
||||
return files
|
||||
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> requestViewerSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
return await _fileService.requestViewerSession(orgId, fileId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EditorSession> requestEditorSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
return await _fileService.requestEditorSession(orgId, fileId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
await _fileService.saveAnnotations(orgId, fileId, annotations);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import '../models/user.dart';
|
||||
import '../repositories/auth_repository.dart';
|
||||
|
||||
class MockAuthRepository implements AuthRepository {
|
||||
@override
|
||||
Future<User> login(String email, String password) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (email.isNotEmpty && password.isNotEmpty) {
|
||||
return User(
|
||||
id: 'mock-user-id',
|
||||
username: 'mockuser',
|
||||
email: email,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
throw Exception('Invalid credentials');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
// Mock logout
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User?> getCurrentUser() async {
|
||||
// Mock: return null or a user
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import '../models/file_item.dart';
|
||||
import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import '../models/document_capabilities.dart';
|
||||
import '../models/api_error.dart';
|
||||
import '../repositories/file_repository.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class MockFileRepository implements FileRepository {
|
||||
final Map<String, List<FileItem>> _orgFiles = {};
|
||||
|
||||
List<FileItem> _getFilesForOrg(String orgId) {
|
||||
if (!_orgFiles.containsKey(orgId)) {
|
||||
// Initialize with different files per org
|
||||
if (orgId == 'org1') {
|
||||
_orgFiles[orgId] = [
|
||||
FileItem(
|
||||
name: 'Personal Documents',
|
||||
path: '/Personal Documents',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'Photos',
|
||||
path: '/Photos',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'resume.pdf',
|
||||
path: '/resume.pdf',
|
||||
type: FileType.file,
|
||||
size: 1024,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'notes.txt',
|
||||
path: '/notes.txt',
|
||||
type: FileType.file,
|
||||
size: 256,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
];
|
||||
} else if (orgId == 'org2') {
|
||||
_orgFiles[orgId] = [
|
||||
FileItem(
|
||||
name: 'Company Reports',
|
||||
path: '/Company Reports',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'annual_report.pdf',
|
||||
path: '/annual_report.pdf',
|
||||
type: FileType.file,
|
||||
size: 2048,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'presentation.pptx',
|
||||
path: '/presentation.pptx',
|
||||
type: FileType.file,
|
||||
size: 4096,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
];
|
||||
} else if (orgId == 'org3') {
|
||||
_orgFiles[orgId] = [
|
||||
FileItem(
|
||||
name: 'Project Code',
|
||||
path: '/Project Code',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'side_project.dart',
|
||||
path: '/side_project.dart',
|
||||
type: FileType.file,
|
||||
size: 512,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
// Default for new orgs
|
||||
_orgFiles[orgId] = [];
|
||||
}
|
||||
}
|
||||
return _orgFiles[orgId]!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
if (path == '/') {
|
||||
return files.where((f) => !f.path.substring(1).contains('/')).toList();
|
||||
} else {
|
||||
return files
|
||||
.where((f) => f.path.startsWith('$path/') && f.path != path)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FileItem?> getFile(String orgId, String path) async {
|
||||
final files = _getFilesForOrg(orgId);
|
||||
final index = files.indexWhere((f) => f.path == path);
|
||||
return index != -1 ? files[index] : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EditorSession> requestEditorSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
// Mock: determine editability
|
||||
final isEditable =
|
||||
fileId.endsWith('.docx') ||
|
||||
fileId.endsWith('.xlsx') ||
|
||||
fileId.endsWith('.pptx');
|
||||
final editUrl = Uri.parse(
|
||||
'https://office.b0esche.cloud/editor/$orgId/$fileId?editable=$isEditable',
|
||||
);
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
|
||||
return EditorSession(
|
||||
editUrl: editUrl,
|
||||
readOnly: !isEditable,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
final files = _getFilesForOrg(orgId);
|
||||
files.removeWhere((f) => f.path == path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createFolder(
|
||||
String orgId,
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final normalizedName = folderName.startsWith('/')
|
||||
? folderName.substring(1)
|
||||
: folderName;
|
||||
final newPath = parentPath == '/'
|
||||
? '/$normalizedName'
|
||||
: '$parentPath/$normalizedName';
|
||||
final files = _getFilesForOrg(orgId);
|
||||
files.add(
|
||||
FileItem(
|
||||
name: normalizedName,
|
||||
path: newPath,
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> moveFile(
|
||||
String orgId,
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
final fileIndex = files.indexWhere((f) => f.path == sourcePath);
|
||||
if (fileIndex != -1) {
|
||||
final file = files[fileIndex];
|
||||
final newName = file.path.split('/').last;
|
||||
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
|
||||
files[fileIndex] = FileItem(
|
||||
name: file.name,
|
||||
path: newPath,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
final fileIndex = files.indexWhere((f) => f.path == path);
|
||||
if (fileIndex != -1) {
|
||||
final file = files[fileIndex];
|
||||
final parentPath = p.dirname(path);
|
||||
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
|
||||
files[fileIndex] = FileItem(
|
||||
name: newName,
|
||||
path: newPath,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
return files
|
||||
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
files.add(file);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> requestViewerSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (fileId.contains('forbidden')) {
|
||||
throw ApiError(
|
||||
code: 'permission_denied',
|
||||
message: 'Access denied',
|
||||
status: 403,
|
||||
);
|
||||
}
|
||||
if (fileId.contains('notfound')) {
|
||||
throw ApiError(code: 'not_found', message: 'File not found', status: 404);
|
||||
}
|
||||
// Mock: assume fileId is path, determine if PDF
|
||||
final isPdf = fileId.endsWith('.pdf');
|
||||
final caps = DocumentCapabilities(
|
||||
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
|
||||
canAnnotate: isPdf,
|
||||
isPdf: isPdf,
|
||||
);
|
||||
// Mock URL
|
||||
final viewUrl = Uri.parse(
|
||||
'https://office.b0esche.cloud/viewer/$orgId/$fileId',
|
||||
);
|
||||
final token = 'mock-viewer-token';
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
|
||||
return ViewerSession(
|
||||
viewUrl: viewUrl,
|
||||
capabilities: caps,
|
||||
token: token,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// Mock: just delay, assume success
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import '../models/api_error.dart';
|
||||
import '../blocs/session/session_bloc.dart';
|
||||
import '../blocs/session/session_event.dart';
|
||||
@@ -13,7 +14,9 @@ class ApiClient {
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(
|
||||
seconds: 120,
|
||||
), // Increased for file uploads and org operations
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,23 +32,13 @@ class ApiClient {
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// Try refresh
|
||||
final refreshSuccess = await _tryRefreshToken();
|
||||
if (refreshSuccess) {
|
||||
// Retry the request
|
||||
final token = _getCurrentToken();
|
||||
if (token != null) {
|
||||
error.requestOptions.headers['Authorization'] = 'Bearer $token';
|
||||
try {
|
||||
final response = await _dio.fetch(error.requestOptions);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
// If retry fails, proceed to error
|
||||
}
|
||||
}
|
||||
final path = error.requestOptions.path;
|
||||
// Do not expire session for auth endpoints; show inline error instead
|
||||
final isAuthEndpoint = path.startsWith('/auth/');
|
||||
if (!isAuthEndpoint) {
|
||||
// Session expired, trigger logout
|
||||
_sessionBloc.add(SessionExpired());
|
||||
}
|
||||
// If refresh failed, logout
|
||||
_sessionBloc.add(SessionExpired());
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
@@ -53,6 +46,10 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
String get baseUrl => _dio.options.baseUrl;
|
||||
|
||||
String? get currentToken => _getCurrentToken();
|
||||
|
||||
String? _getCurrentToken() {
|
||||
// Get from SessionBloc state
|
||||
final state = _sessionBloc.state;
|
||||
@@ -62,20 +59,6 @@ class ApiClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> _tryRefreshToken() async {
|
||||
try {
|
||||
final response = await _dio.post('/auth/refresh');
|
||||
if (response.statusCode == 200) {
|
||||
final newToken = response.data['token'];
|
||||
_sessionBloc.add(SessionRefreshed(newToken));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Refresh failed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<T> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
@@ -89,6 +72,18 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getRaw(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
@@ -96,12 +91,77 @@ class ApiClient {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(path, data: data);
|
||||
|
||||
return fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> getBytes(String path) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
path,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postRaw(String path, {dynamic data}) async {
|
||||
try {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
required T Function(dynamic data) fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.patch(path, data: data);
|
||||
return fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
required T Function(dynamic data) fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put(path, data: data);
|
||||
return fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> putRaw(String path, {dynamic data}) async {
|
||||
try {
|
||||
final response = await _dio.put(path, data: data);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(String path) async {
|
||||
try {
|
||||
await _dio.delete(path);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<T>> getList<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
@@ -109,7 +169,69 @@ class ApiClient {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
return (response.data as List).map(fromJson).toList();
|
||||
final data = response.data;
|
||||
if (data == null) return [];
|
||||
return (data as List).map(fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// User profile methods
|
||||
Future<Map<String, dynamic>> getUserProfile() async {
|
||||
return getRaw('/user/profile');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> updateUserProfile({
|
||||
required String displayName,
|
||||
String? email,
|
||||
}) async {
|
||||
final data = <String, dynamic>{'displayName': displayName};
|
||||
if (email != null) data['email'] = email;
|
||||
|
||||
return putRaw('/user/profile', data: data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
return postRaw(
|
||||
'/user/change-password',
|
||||
data: {'currentPassword': currentPassword, 'newPassword': newPassword},
|
||||
);
|
||||
}
|
||||
|
||||
// Avatar upload
|
||||
Future<Map<String, dynamic>> uploadAvatar(
|
||||
List<int> imageBytes,
|
||||
String filename, {
|
||||
ProgressCallback? onSendProgress,
|
||||
}) async {
|
||||
final formData = FormData.fromMap({
|
||||
'avatar': MultipartFile.fromBytes(
|
||||
imageBytes,
|
||||
filename: filename,
|
||||
contentType: MediaType('image', filename.split('.').last),
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/user/avatar',
|
||||
data: formData,
|
||||
onSendProgress: onSendProgress,
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteAccount() async {
|
||||
try {
|
||||
final response = await _dio.delete('/user/account');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
@@ -131,8 +253,17 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
String code = data?['code'] ?? 'UNKNOWN';
|
||||
String message = data?['message'] ?? 'Unknown error';
|
||||
// Only try to extract code/message if data is a Map
|
||||
String code = 'UNKNOWN';
|
||||
String message = 'Unknown error';
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
code = data['code'] ?? 'UNKNOWN';
|
||||
message = data['message'] ?? 'Unknown error';
|
||||
} else if (data != null) {
|
||||
message = data.toString();
|
||||
}
|
||||
|
||||
return ApiError(code: code, message: message, status: status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import 'dart:typed_data';
|
||||
import '../models/file_item.dart';
|
||||
import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import 'api_client.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:archive/archive.dart';
|
||||
|
||||
class FileService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
FileService(this._apiClient);
|
||||
|
||||
String get baseUrl => _apiClient.baseUrl;
|
||||
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
if (path.isEmpty) {
|
||||
throw Exception('Path cannot be empty');
|
||||
}
|
||||
final pathParam = {'path': path};
|
||||
if (orgId.isEmpty) {
|
||||
return await _apiClient.getList(
|
||||
'/user/files',
|
||||
queryParameters: pathParam,
|
||||
fromJson: (data) => FileItem(
|
||||
id: data['id'],
|
||||
name: data['name'],
|
||||
path: data['path'],
|
||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||
size: data['size'],
|
||||
lastModified: DateTime.parse(data['lastModified']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/files',
|
||||
queryParameters: pathParam,
|
||||
fromJson: (data) => FileItem(
|
||||
id: data['id'],
|
||||
name: data['name'],
|
||||
path: data['path'],
|
||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||
@@ -30,11 +53,85 @@ class FileService {
|
||||
}
|
||||
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
throw UnimplementedError();
|
||||
// If bytes or localPath available, send multipart upload with field 'file'
|
||||
final Map<String, dynamic> fields = {'path': file.path};
|
||||
FormData formData;
|
||||
|
||||
if (file.bytes != null) {
|
||||
formData = FormData.fromMap({
|
||||
...fields,
|
||||
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
||||
});
|
||||
} else if (file.localPath != null) {
|
||||
formData = FormData.fromMap({
|
||||
...fields,
|
||||
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
|
||||
});
|
||||
} else {
|
||||
// Fallback to metadata-only create (folders or client that can't send file content)
|
||||
final data = {
|
||||
'name': file.name,
|
||||
'path': file.path,
|
||||
'type': file.type == FileType.file ? 'file' : 'folder',
|
||||
'size': file.size,
|
||||
};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
throw UnimplementedError();
|
||||
final data = {'path': path};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post(
|
||||
'/user/files/delete',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files/delete',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> getDownloadUrl(String orgId, String path) async {
|
||||
// Return the download URL for the file
|
||||
if (orgId.isEmpty) {
|
||||
return '/user/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
|
||||
Future<String> getFileUrl({
|
||||
required String orgId,
|
||||
required String filePath,
|
||||
String? fileName,
|
||||
}) async {
|
||||
// Get authentication token
|
||||
final token = _apiClient.currentToken;
|
||||
if (token == null) {
|
||||
throw Exception('No authentication token available');
|
||||
}
|
||||
|
||||
// Return the full download URL with token
|
||||
final path = orgId.isEmpty
|
||||
? '/user/files/download?path=${Uri.encodeComponent(filePath)}&token=${Uri.encodeComponent(token)}'
|
||||
: '/orgs/$orgId/files/download?path=${Uri.encodeComponent(filePath)}&token=${Uri.encodeComponent(token)}';
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
|
||||
Future<void> createFolder(
|
||||
@@ -42,7 +139,41 @@ class FileService {
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
throw UnimplementedError();
|
||||
// Normalize folder name to avoid accidental leading slashes creating double-slash paths
|
||||
final normalizedName = folderName
|
||||
.replaceAll(RegExp(r'^/+'), '')
|
||||
.replaceAll(RegExp(r'/+$'), '');
|
||||
if (normalizedName.isEmpty) {
|
||||
throw Exception('Folder name cannot be empty');
|
||||
}
|
||||
|
||||
// Construct proper path: /parent/folder or /folder for root
|
||||
String path;
|
||||
if (parentPath == '/') {
|
||||
path = '/$normalizedName';
|
||||
} else {
|
||||
// Ensure parentPath doesn't end with / before appending
|
||||
final cleanParent = parentPath.endsWith('/')
|
||||
? parentPath.substring(0, parentPath.length - 1)
|
||||
: parentPath;
|
||||
path = '$cleanParent/$normalizedName';
|
||||
}
|
||||
|
||||
final data = {
|
||||
'name': normalizedName,
|
||||
'path': path,
|
||||
'type': 'folder',
|
||||
'size': 0,
|
||||
};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> moveFile(
|
||||
@@ -50,7 +181,14 @@ class FileService {
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) async {
|
||||
throw UnimplementedError();
|
||||
final endpoint = orgId.isEmpty
|
||||
? '/user/files/move'
|
||||
: '/orgs/$orgId/files/move';
|
||||
await _apiClient.post(
|
||||
endpoint,
|
||||
data: {'sourcePath': sourcePath, 'targetPath': targetPath},
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
@@ -65,11 +203,14 @@ class FileService {
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
throw Exception('OrgId and fileId cannot be empty');
|
||||
if (fileId.isEmpty) {
|
||||
throw Exception('fileId cannot be empty');
|
||||
}
|
||||
final path = orgId.isEmpty
|
||||
? '/user/files/$fileId/view'
|
||||
: '/orgs/$orgId/files/$fileId/view';
|
||||
return await _apiClient.get(
|
||||
'/orgs/$orgId/files/$fileId/view',
|
||||
path,
|
||||
fromJson: (data) => ViewerSession.fromJson(data),
|
||||
);
|
||||
}
|
||||
@@ -78,11 +219,14 @@ class FileService {
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
throw Exception('OrgId and fileId cannot be empty');
|
||||
if (fileId.isEmpty) {
|
||||
throw Exception('fileId cannot be empty');
|
||||
}
|
||||
final path = orgId.isEmpty
|
||||
? '/user/files/$fileId/edit'
|
||||
: '/orgs/$orgId/files/$fileId/edit';
|
||||
return await _apiClient.get(
|
||||
'/orgs/$orgId/files/$fileId/edit',
|
||||
path,
|
||||
fromJson: (data) => EditorSession.fromJson(data),
|
||||
);
|
||||
}
|
||||
@@ -92,11 +236,14 @@ class FileService {
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
throw Exception('OrgId and fileId cannot be empty');
|
||||
if (fileId.isEmpty) {
|
||||
throw Exception('fileId cannot be empty');
|
||||
}
|
||||
final path = orgId.isEmpty
|
||||
? '/user/files/$fileId/annotations'
|
||||
: '/orgs/$orgId/files/$fileId/annotations';
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files/$fileId/annotations',
|
||||
path,
|
||||
data: {
|
||||
'annotations': annotations.map((a) => a.toJson()).toList(),
|
||||
'baseVersionId': '1', // mock
|
||||
@@ -104,4 +251,149 @@ class FileService {
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an empty .docx document and returns the created file's ID
|
||||
Future<String> createDocument(
|
||||
String orgId,
|
||||
String parentPath,
|
||||
String fileName,
|
||||
) async {
|
||||
// Ensure filename has .docx extension
|
||||
final docxName = fileName.endsWith('.docx') ? fileName : '$fileName.docx';
|
||||
|
||||
// Generate minimal valid DOCX file (Office Open XML format)
|
||||
final bytes = _generateEmptyDocx();
|
||||
|
||||
// Send parent directory as 'path' parameter
|
||||
final formData = FormData.fromMap({
|
||||
'path': parentPath,
|
||||
'file': MultipartFile.fromBytes(bytes, filename: docxName),
|
||||
});
|
||||
|
||||
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||
final response = await _apiClient.post(
|
||||
endpoint,
|
||||
data: formData,
|
||||
fromJson: (d) => d as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
// Return the file ID from the response
|
||||
return response['id'] as String;
|
||||
}
|
||||
|
||||
/// Generates a minimal valid DOCX file (empty document)
|
||||
Uint8List _generateEmptyDocx() {
|
||||
final archive = Archive();
|
||||
|
||||
// [Content_Types].xml - defines content types
|
||||
const contentTypes =
|
||||
'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'[Content_Types].xml',
|
||||
contentTypes.length,
|
||||
Uint8List.fromList(contentTypes.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// _rels/.rels - root relationships
|
||||
const rootRels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'_rels/.rels',
|
||||
rootRels.length,
|
||||
Uint8List.fromList(rootRels.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// word/document.xml - the actual document content (empty)
|
||||
const documentXml =
|
||||
'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w14">
|
||||
<w:body>
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="Normal"/>
|
||||
</w:pPr>
|
||||
</w:p>
|
||||
<w:sectPr>
|
||||
<w:pgSz w:w="12240" w:h="15840"/>
|
||||
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
|
||||
</w:sectPr>
|
||||
</w:body>
|
||||
</w:document>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'word/document.xml',
|
||||
documentXml.length,
|
||||
Uint8List.fromList(documentXml.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// word/styles.xml - document styles
|
||||
const stylesXml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w14">
|
||||
<w:docDefaults>
|
||||
<w:rPrDefault>
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri" w:eastAsia="Calibri"/>
|
||||
<w:sz w:val="22"/>
|
||||
<w:szCs w:val="22"/>
|
||||
<w:lang w:val="en-US" w:eastAsia="en-US" w:bidi="ar-SA"/>
|
||||
</w:rPr>
|
||||
</w:rPrDefault>
|
||||
<w:pPrDefault>
|
||||
<w:pPr>
|
||||
<w:spacing w:after="160" w:before="0" w:line="259" w:lineRule="auto"/>
|
||||
</w:pPr>
|
||||
</w:pPrDefault>
|
||||
</w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
<w:qFormat/>
|
||||
<w:pPr>
|
||||
<w:spacing w:after="160" w:before="0" w:line="259" w:lineRule="auto"/>
|
||||
</w:pPr>
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri" w:eastAsia="Calibri"/>
|
||||
<w:sz w:val="22"/>
|
||||
<w:szCs w:val="22"/>
|
||||
<w:lang w:val="en-US" w:eastAsia="en-US" w:bidi="ar-SA"/>
|
||||
</w:rPr>
|
||||
</w:style>
|
||||
</w:styles>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'word/styles.xml',
|
||||
stylesXml.length,
|
||||
Uint8List.fromList(stylesXml.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// word/_rels/document.xml.rels - document relationships (empty but required)
|
||||
const docRels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
</Relationships>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'word/_rels/document.xml.rels',
|
||||
docRels.length,
|
||||
Uint8List.fromList(docRels.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// Encode as ZIP
|
||||
final zipEncoder = ZipEncoder();
|
||||
final zipData = zipEncoder.encode(archive);
|
||||
return Uint8List.fromList(zipData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import '../blocs/organization/organization_state.dart';
|
||||
import '../models/organization.dart';
|
||||
import '../models/user.dart';
|
||||
import 'api_client.dart';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
class OrgApi {
|
||||
final ApiClient _apiClient;
|
||||
@@ -14,10 +17,127 @@ class OrgApi {
|
||||
}
|
||||
|
||||
Future<Organization> createOrganization(String name) async {
|
||||
return await _apiClient.post(
|
||||
'/orgs',
|
||||
data: {'name': name},
|
||||
fromJson: (data) => Organization.fromJson(data),
|
||||
developer.log('POST /orgs with payload: {"name": "$name"}', name: 'OrgApi');
|
||||
|
||||
try {
|
||||
final result = await _apiClient.post(
|
||||
'/orgs',
|
||||
data: {'name': name},
|
||||
fromJson: (data) => Organization.fromJson(data),
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Member>> getMembers(String orgId) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/members',
|
||||
fromJson: (data) => Member.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateMemberRole(
|
||||
String orgId,
|
||||
String userId,
|
||||
String role,
|
||||
) async {
|
||||
await _apiClient.patch(
|
||||
'/orgs/$orgId/members/$userId',
|
||||
data: {'role': role},
|
||||
fromJson: (data) => data,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeMember(String orgId, String userId) async {
|
||||
await _apiClient.delete('/orgs/$orgId/members/$userId');
|
||||
}
|
||||
|
||||
Future<List<User>> searchUsers(String orgId, String query) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/users/search?q=$query',
|
||||
fromJson: (data) => User.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Invitation> createInvitation(
|
||||
String orgId,
|
||||
String username,
|
||||
String role,
|
||||
) async {
|
||||
final result = await _apiClient.post(
|
||||
'/orgs/$orgId/invitations',
|
||||
data: {'username': username, 'role': role},
|
||||
fromJson: (data) => Invitation.fromJson(data),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<Invitation>> getInvitations(String orgId) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/invitations',
|
||||
fromJson: (data) => Invitation.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelInvitation(String orgId, String invitationId) async {
|
||||
await _apiClient.delete('/orgs/$orgId/invitations/$invitationId');
|
||||
}
|
||||
|
||||
Future<JoinRequest> createJoinRequest(
|
||||
String orgId, {
|
||||
String? inviteToken,
|
||||
}) async {
|
||||
final data = {'orgId': orgId};
|
||||
if (inviteToken != null) {
|
||||
data['inviteToken'] = inviteToken;
|
||||
}
|
||||
final result = await _apiClient.post(
|
||||
'/join-requests',
|
||||
data: data,
|
||||
fromJson: (data) => JoinRequest.fromJson(data),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<JoinRequest>> getJoinRequests(String orgId) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/join-requests',
|
||||
fromJson: (data) => JoinRequest.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> acceptJoinRequest(
|
||||
String orgId,
|
||||
String requestId,
|
||||
String role,
|
||||
) async {
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/join-requests/$requestId/accept',
|
||||
data: {'role': role},
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> rejectJoinRequest(String orgId, String requestId) async {
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/join-requests/$requestId/reject',
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getInviteLink(String orgId) async {
|
||||
final result = await _apiClient.getRaw('/orgs/$orgId/invite-link');
|
||||
return result['inviteLink'] as String?;
|
||||
}
|
||||
|
||||
Future<String> regenerateInviteLink(String orgId) async {
|
||||
final result = await _apiClient.post(
|
||||
'/orgs/$orgId/invite-link/regenerate',
|
||||
fromJson: (data) => data,
|
||||
);
|
||||
return result['inviteLink'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const Color primaryBackground = Colors.black;
|
||||
static const Color secondaryBackground = Colors.grey;
|
||||
static const Color accentColor = Color.fromARGB(255, 100, 200, 255);
|
||||
static const Color secondaryText = Colors.white70;
|
||||
static const Color primaryText = Colors.white;
|
||||
static const Color errorColor = Colors.redAccent;
|
||||
static const Color glassBackground = Colors.white;
|
||||
static const double glassOpacity = 0.1;
|
||||
static const double glassBlur = 10;
|
||||
|
||||
@@ -6,11 +6,15 @@ class ModernGlassButton extends StatefulWidget {
|
||||
final VoidCallback onPressed;
|
||||
final Widget child;
|
||||
final bool isLoading;
|
||||
final EdgeInsets padding;
|
||||
final bool showShadows;
|
||||
|
||||
const ModernGlassButton({
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.isLoading = false,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
this.showShadows = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -61,34 +65,32 @@ class _ModernGlassButtonState extends State<ModernGlassButton>
|
||||
child: Stack(
|
||||
children: [
|
||||
// Shadow layer
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
blurRadius: _isHovered ? 24 : 12,
|
||||
spreadRadius: _isHovered ? 2 : 0,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
BoxShadow(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
if (widget.showShadows)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
blurRadius: _isHovered ? 24 : 12,
|
||||
spreadRadius: _isHovered ? 2 : 0,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
BoxShadow(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Glass button with gradient
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: widget.padding,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/file_item.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
||||
class FileExplorerViewModel extends ChangeNotifier {
|
||||
final FileService _fileService;
|
||||
|
||||
FileExplorerViewModel(this._fileService);
|
||||
|
||||
List<FileItem> _files = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
String _currentPath = '/';
|
||||
|
||||
List<FileItem> get files => _files;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String get currentPath => _currentPath;
|
||||
|
||||
Future<void> loadFiles([String? path]) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
if (path != null) _currentPath = path;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_files = await _fileService.getFiles("", _currentPath);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_files = [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> uploadFile(FileItem file) async {
|
||||
try {
|
||||
await _fileService.uploadFile("", file);
|
||||
await loadFiles(); // Reload files
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String path) async {
|
||||
try {
|
||||
await _fileService.deleteFile("", path);
|
||||
await loadFiles(); // Reload files
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/auth_service.dart';
|
||||
|
||||
class LoginViewModel extends ChangeNotifier {
|
||||
final AuthService _authService;
|
||||
|
||||
LoginViewModel(this._authService);
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
User? _currentUser;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
User? get currentUser => _currentUser;
|
||||
bool get isLoggedIn => _currentUser != null;
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_currentUser = await _authService.login(email, password);
|
||||
_error = null;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_currentUser = null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _authService.logout();
|
||||
_currentUser = null;
|
||||
_error = null;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkCurrentUser() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_currentUser = await _authService.getCurrentUser();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
1637
b0esche_cloud/lib/widgets/account_settings_dialog.dart
Normal file
312
b0esche_cloud/lib/widgets/audio_player_bar.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:async';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
|
||||
import 'package:just_audio/just_audio.dart' as just_audio;
|
||||
import 'web_audio_player.dart' as web_audio;
|
||||
|
||||
class AudioPlayerBar extends StatefulWidget {
|
||||
final String fileName;
|
||||
final String fileUrl;
|
||||
final String? mimeType;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const AudioPlayerBar({
|
||||
super.key,
|
||||
required this.fileName,
|
||||
required this.fileUrl,
|
||||
this.mimeType,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioPlayerBar> createState() => _AudioPlayerBarState();
|
||||
}
|
||||
|
||||
class _AudioPlayerBarState extends State<AudioPlayerBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
dynamic _audioPlayer;
|
||||
late AnimationController _iconController;
|
||||
Duration _duration = Duration.zero;
|
||||
Duration _position = Duration.zero;
|
||||
bool _isPlaying = false;
|
||||
bool _isLoading = true;
|
||||
StreamSubscription? _positionSubscription;
|
||||
StreamSubscription? _durationSubscription;
|
||||
StreamSubscription? _playingSubscription;
|
||||
StreamSubscription? _errorSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_audioPlayer = kIsWeb ? web_audio.AudioPlayer() : just_audio.AudioPlayer();
|
||||
_iconController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
_initAudio();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_iconController.dispose();
|
||||
_positionSubscription?.cancel();
|
||||
_durationSubscription?.cancel();
|
||||
_playingSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
_audioPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _errorMsg;
|
||||
Future<void> _initAudio() async {
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
await _audioPlayer.setUrl(widget.fileUrl, mimeType: widget.mimeType);
|
||||
_durationSubscription = _audioPlayer.durationStream.listen((d) {
|
||||
if (d != null) {
|
||||
setState(() {
|
||||
_duration = d;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_positionSubscription = _audioPlayer.positionStream.listen((pos) {
|
||||
setState(() {
|
||||
_position = pos;
|
||||
});
|
||||
});
|
||||
|
||||
_playingSubscription = _audioPlayer.playingStream.listen((playing) {
|
||||
setState(() {
|
||||
_isPlaying = playing;
|
||||
});
|
||||
if (playing) {
|
||||
if (mounted) _iconController.forward();
|
||||
} else {
|
||||
if (mounted) _iconController.reverse();
|
||||
}
|
||||
});
|
||||
|
||||
_errorSubscription = _audioPlayer.errorStream.listen((error) {
|
||||
setState(() {
|
||||
_errorMsg = error.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
});
|
||||
|
||||
await _audioPlayer.play();
|
||||
} else {
|
||||
await _audioPlayer.setAudioSource(
|
||||
just_audio.AudioSource.uri(Uri.parse(widget.fileUrl)),
|
||||
);
|
||||
_audioPlayer.durationStream.firstWhere((d) => d != null).then((
|
||||
d,
|
||||
) async {
|
||||
setState(() {
|
||||
_duration = d ?? Duration.zero;
|
||||
_isLoading = false;
|
||||
});
|
||||
try {
|
||||
await _audioPlayer.play();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
final player = _audioPlayer as dynamic;
|
||||
if (player.playerState?.playing == true) {
|
||||
if (mounted) _iconController.forward();
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMsg = 'Audio could not be played.';
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMsg =
|
||||
'Audio playback error: ${e is Exception ? e.toString() : 'Unknown error'}';
|
||||
});
|
||||
}
|
||||
});
|
||||
_audioPlayer.positionStream.listen((pos) {
|
||||
setState(() {
|
||||
_position = pos;
|
||||
});
|
||||
});
|
||||
|
||||
final player = _audioPlayer as dynamic;
|
||||
player.playerStateStream.listen((state) {
|
||||
setState(() {
|
||||
_isPlaying = state.playing;
|
||||
});
|
||||
if (state.playing) {
|
||||
if (mounted) _iconController.forward();
|
||||
} else {
|
||||
if (mounted) _iconController.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMsg =
|
||||
'Audio load error: ${e is Exception ? e.toString() : 'Unknown error'}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePlayPause() {
|
||||
if (_isPlaying) {
|
||||
_audioPlayer.pause();
|
||||
_iconController.reverse();
|
||||
} else {
|
||||
// If at end, seek to start
|
||||
if (_position >= _duration && _duration > Duration.zero) {
|
||||
_audioPlayer.seek(Duration.zero);
|
||||
}
|
||||
_audioPlayer.play();
|
||||
_iconController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenwidth = MediaQuery.of(context).size.width;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: 48,
|
||||
width: screenwidth > 420 ? screenwidth * 0.2 : screenwidth * 0.85,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: AppTheme.glassDecoration.copyWith(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ModernGlassButton(
|
||||
onPressed: _isLoading ? () {} : _handlePlayPause,
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -4),
|
||||
child: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: _iconController,
|
||||
color: AppTheme.primaryText,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// File name and slider
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.fileName,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
SizedBox(
|
||||
height: 18,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2.2,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
activeTrackColor: AppTheme.accentColor,
|
||||
inactiveTrackColor: AppTheme.accentColor.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: _duration.inMilliseconds.toDouble(),
|
||||
value: _position.inMilliseconds
|
||||
.clamp(0, _duration.inMilliseconds)
|
||||
.toDouble(),
|
||||
onChanged: _isLoading
|
||||
? null
|
||||
: (value) {
|
||||
_audioPlayer.seek(
|
||||
Duration(milliseconds: value.toInt()),
|
||||
);
|
||||
},
|
||||
activeColor: AppTheme.accentColor,
|
||||
inactiveColor: AppTheme.accentColor.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorMsg != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
_errorMsg!,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
Text(
|
||||
'${_formatDuration(_position)} / ${_formatDuration(_duration)}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.secondaryText,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.onClose != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onClose!,
|
||||
child: const Text(
|
||||
'×',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Utility to format duration
|
||||
String _formatDuration(Duration d) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final minutes = twoDigits(d.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(d.inSeconds.remainder(60));
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
188
b0esche_cloud/lib/widgets/file_viewer_dispatch.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:syncfusion_flutter_core/theme.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'dart:ui_web' as ui_web;
|
||||
import '../theme/app_theme.dart';
|
||||
import 'audio_player_bar.dart';
|
||||
|
||||
class FileViewerDispatch {
|
||||
static Widget buildFileViewer(
|
||||
BuildContext context,
|
||||
String url,
|
||||
String mimeType, {
|
||||
String? token,
|
||||
String? fileName,
|
||||
required String viewerId,
|
||||
void Function(PdfHyperlinkClickedDetails)? onHyperlinkClicked,
|
||||
}) {
|
||||
final headers = token != null
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: <String, String>{};
|
||||
|
||||
if (mimeType == 'application/pdf') {
|
||||
return SfTheme(
|
||||
data: SfThemeData(
|
||||
pdfViewerThemeData: SfPdfViewerThemeData(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
progressBarColor: AppTheme.accentColor,
|
||||
scrollStatusStyle: PdfScrollStatusStyle(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
),
|
||||
scrollHeadStyle: PdfScrollHeadStyle(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SfPdfViewer.network(
|
||||
url,
|
||||
headers: headers,
|
||||
canShowScrollHead: false,
|
||||
canShowScrollStatus: false,
|
||||
enableDoubleTapZooming: true,
|
||||
enableTextSelection: false,
|
||||
onHyperlinkClicked: onHyperlinkClicked,
|
||||
onDocumentLoadFailed: (details) {
|
||||
// Handle error
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (mimeType.startsWith('video/')) {
|
||||
if (kIsWeb) {
|
||||
// Use HTML video element for web
|
||||
ui_web.platformViewRegistry.registerViewFactory(viewerId, (int viewId) {
|
||||
final videoElement = web.HTMLVideoElement()
|
||||
..src = url
|
||||
..controls = true
|
||||
..autoplay = false
|
||||
..crossOrigin = 'anonymous'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.objectFit = 'contain';
|
||||
|
||||
// Add headers if token
|
||||
if (token != null) {
|
||||
// For web, headers are not directly supported, but since it's public or auth, assume ok
|
||||
}
|
||||
|
||||
videoElement.onError.listen((event) {
|
||||
// Handle error
|
||||
});
|
||||
|
||||
return videoElement;
|
||||
});
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: HtmlElementView(viewType: viewerId),
|
||||
);
|
||||
} else {
|
||||
// Use VideoPlayer for mobile
|
||||
final controller = VideoPlayerController.networkUrl(Uri.parse(url));
|
||||
return FutureBuilder<void>(
|
||||
future: controller.initialize(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
return AspectRatio(
|
||||
aspectRatio: controller.value.aspectRatio,
|
||||
child: VideoPlayer(controller),
|
||||
);
|
||||
} else {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
return Center(
|
||||
child: AudioPlayerBar(
|
||||
fileName: fileName ?? 'Audio',
|
||||
fileUrl: url,
|
||||
mimeType: mimeType,
|
||||
),
|
||||
);
|
||||
} else if (mimeType.startsWith('image/')) {
|
||||
Widget child;
|
||||
if (kIsWeb && token == null) {
|
||||
// Use HTML img element for web public shares to handle CORS
|
||||
ui_web.platformViewRegistry.registerViewFactory(viewerId, (int viewId) {
|
||||
final imgElement = web.HTMLImageElement()
|
||||
..src = url
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.objectFit = 'contain'
|
||||
..crossOrigin = 'anonymous';
|
||||
|
||||
imgElement.onError.listen((event) {
|
||||
// Handle error
|
||||
});
|
||||
|
||||
return imgElement;
|
||||
});
|
||||
|
||||
child = HtmlElementView(viewType: viewerId);
|
||||
} else {
|
||||
// For mobile or authenticated web, use Image.network with headers
|
||||
child = Image.network(
|
||||
url,
|
||||
headers: headers,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Failed to load image',
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget viewerChild = child;
|
||||
if (!(kIsWeb && token == null)) {
|
||||
// Use InteractiveViewer for mobile or authenticated web
|
||||
viewerChild = InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 4.0,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return Container(color: AppTheme.primaryBackground, child: viewerChild);
|
||||
} else {
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 80,
|
||||
color: AppTheme.primaryText.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'File type not supported for preview',
|
||||
style: TextStyle(color: AppTheme.primaryText, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Download
|
||||
final anchor = web.HTMLAnchorElement()
|
||||
..href = url
|
||||
..download = fileName ?? 'download';
|
||||
anchor.click();
|
||||
},
|
||||
child: const Text('Download File'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
722
b0esche_cloud/lib/widgets/organization_settings_dialog.dart
Normal file
@@ -0,0 +1,722 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:developer' as developer;
|
||||
import '../blocs/organization/organization_state.dart';
|
||||
import '../blocs/permission/permission_state.dart';
|
||||
import '../models/organization.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/org_api.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
|
||||
class OrganizationSettingsDialog extends StatefulWidget {
|
||||
final Organization organization;
|
||||
final PermissionState permissionState;
|
||||
final OrgApi orgApi;
|
||||
|
||||
const OrganizationSettingsDialog({
|
||||
super.key,
|
||||
required this.organization,
|
||||
required this.permissionState,
|
||||
required this.orgApi,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OrganizationSettingsDialog> createState() =>
|
||||
_OrganizationSettingsDialogState();
|
||||
}
|
||||
|
||||
class _OrganizationSettingsDialogState
|
||||
extends State<OrganizationSettingsDialog> {
|
||||
int _selectedTabIndex = 0;
|
||||
List<Member> _members = [];
|
||||
List<Invitation> _invitations = [];
|
||||
List<JoinRequest> _joinRequests = [];
|
||||
String? _inviteLink;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<User> _userSuggestions = [];
|
||||
late final TextEditingController usernameController;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
usernameController = TextEditingController();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
usernameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String? error;
|
||||
List<Member> members = [];
|
||||
List<Invitation> invitations = [];
|
||||
List<JoinRequest> joinRequests = [];
|
||||
String? inviteLink;
|
||||
|
||||
try {
|
||||
members = await widget.orgApi.getMembers(widget.organization.id);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Error loading members: $e',
|
||||
name: 'OrganizationSettingsDialog',
|
||||
);
|
||||
error ??= 'Failed to load members: $e';
|
||||
}
|
||||
|
||||
try {
|
||||
invitations = await widget.orgApi.getInvitations(widget.organization.id);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Error loading invitations: $e',
|
||||
name: 'OrganizationSettingsDialog',
|
||||
);
|
||||
error ??= 'Failed to load invitations: $e';
|
||||
}
|
||||
|
||||
try {
|
||||
joinRequests = await widget.orgApi.getJoinRequests(
|
||||
widget.organization.id,
|
||||
);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Error loading join requests: $e',
|
||||
name: 'OrganizationSettingsDialog',
|
||||
);
|
||||
error ??= 'Failed to load join requests: $e';
|
||||
}
|
||||
|
||||
try {
|
||||
inviteLink = await widget.orgApi.getInviteLink(widget.organization.id);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Error loading invite link: $e',
|
||||
name: 'OrganizationSettingsDialog',
|
||||
);
|
||||
error ??= 'Failed to load invite link: $e';
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_members = members;
|
||||
_invitations = invitations;
|
||||
_joinRequests = joinRequests;
|
||||
_inviteLink = inviteLink;
|
||||
_isLoading = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateMemberRole(String userId, String newRole) async {
|
||||
try {
|
||||
await widget.orgApi.updateMemberRole(
|
||||
widget.organization.id,
|
||||
userId,
|
||||
newRole,
|
||||
);
|
||||
await _loadData(); // Refresh
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to update role: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeMember(String userId) async {
|
||||
try {
|
||||
await widget.orgApi.removeMember(widget.organization.id, userId);
|
||||
await _loadData(); // Refresh
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to remove member: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _inviteUser(String username, String role) async {
|
||||
try {
|
||||
await widget.orgApi.createInvitation(
|
||||
widget.organization.id,
|
||||
username,
|
||||
role,
|
||||
);
|
||||
await _loadData(); // Refresh
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to send invitation: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelInvitation(String invitationId) async {
|
||||
try {
|
||||
await widget.orgApi.cancelInvitation(
|
||||
widget.organization.id,
|
||||
invitationId,
|
||||
);
|
||||
await _loadData(); // Refresh
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to cancel invitation: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptJoinRequest(String requestId, String role) async {
|
||||
try {
|
||||
await widget.orgApi.acceptJoinRequest(
|
||||
widget.organization.id,
|
||||
requestId,
|
||||
role,
|
||||
);
|
||||
await _loadData(); // Refresh
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to accept request: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rejectJoinRequest(String requestId) async {
|
||||
try {
|
||||
await widget.orgApi.rejectJoinRequest(widget.organization.id, requestId);
|
||||
await _loadData(); // Refresh
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to reject request: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _regenerateInviteLink() async {
|
||||
try {
|
||||
final newLink = await widget.orgApi.regenerateInviteLink(
|
||||
widget.organization.id,
|
||||
);
|
||||
setState(() => _inviteLink = newLink);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to regenerate link: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _copyInviteLink() {
|
||||
if (_inviteLink != null) {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: 'https://b0esche.cloud/join?token=$_inviteLink'),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Invite link copied to clipboard')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canManage =>
|
||||
widget.permissionState is PermissionLoaded &&
|
||||
(widget.permissionState as PermissionLoaded).capabilities.canAdmin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: 600,
|
||||
height: 500,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Manage ${widget.organization.name}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: AppTheme.secondaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Custom Tabs
|
||||
Row(
|
||||
children: [
|
||||
_buildTabButton('Members', 0),
|
||||
_buildTabButton('Invite', 1),
|
||||
_buildTabButton('Requests', 2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tab content
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
)
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: AppTheme.errorColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: _loadData,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildTabContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(String text, int index) {
|
||||
final isSelected = _selectedTabIndex == index;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selectedTabIndex = index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppTheme.accentColor.withValues(alpha: 0.15)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppTheme.accentColor
|
||||
: AppTheme.secondaryText.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: TextStyle(
|
||||
color: isSelected ? AppTheme.accentColor : AppTheme.secondaryText,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
child: Text(text, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabContent() {
|
||||
switch (_selectedTabIndex) {
|
||||
case 0:
|
||||
return _buildMembersTab();
|
||||
case 1:
|
||||
return _buildInviteTab();
|
||||
case 2:
|
||||
return _buildRequestsTab();
|
||||
default:
|
||||
return _buildMembersTab();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMembersTab() {
|
||||
return ListView.builder(
|
||||
itemCount: _members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = _members[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
member.user.displayName ?? member.user.username,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
subtitle: Text(
|
||||
member.role,
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
trailing: _canManage
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (member.role != 'owner')
|
||||
DropdownButton<String>(
|
||||
value: member.role,
|
||||
items: ['admin', 'member'].map((role) {
|
||||
return DropdownMenuItem(
|
||||
value: role,
|
||||
child: Text(role),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (newRole) {
|
||||
if (newRole != null && newRole != member.role) {
|
||||
_updateMemberRole(member.userId, newRole);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (member.role != 'owner')
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove_circle,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
onPressed: () => _removeMember(member.userId),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInviteTab() {
|
||||
String selectedRole = 'member';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// Pending invitations
|
||||
if (_invitations.isNotEmpty) ...[
|
||||
Text(
|
||||
'Pending Invitations',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
itemCount: _invitations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final inv = _invitations[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
inv.username,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Role: ${inv.role}',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.cancel, color: AppTheme.errorColor),
|
||||
onPressed: () => _cancelInvitation(inv.id),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Invite link section
|
||||
if (_inviteLink != null) ...[
|
||||
const Divider(),
|
||||
Text(
|
||||
'Invite Link',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'https://b0esche.cloud/join?token=${_inviteLink ?? ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: _copyInviteLink,
|
||||
child: const Icon(Icons.content_copy),
|
||||
),
|
||||
if (_canManage) ...[
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: _regenerateInviteLink,
|
||||
child: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
] else if (_canManage) ...[
|
||||
const Divider(),
|
||||
const Text('No invite link available'),
|
||||
],
|
||||
|
||||
// Invite form
|
||||
const Divider(),
|
||||
Text(
|
||||
'Invite New User',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: TextField(
|
||||
controller: usernameController,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppTheme.accentColor),
|
||||
),
|
||||
),
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
onChanged: (value) async {
|
||||
if (value.length > 2) {
|
||||
try {
|
||||
_userSuggestions = await widget.orgApi.searchUsers(
|
||||
widget.organization.id,
|
||||
value,
|
||||
);
|
||||
} catch (e) {
|
||||
_userSuggestions = [];
|
||||
}
|
||||
setState(() {});
|
||||
} else {
|
||||
_userSuggestions = [];
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
buttonTheme: ButtonThemeData(splashColor: Colors.transparent),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: selectedRole,
|
||||
items: ['admin', 'member'].map((role) {
|
||||
return DropdownMenuItem(
|
||||
value: role,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Text(role),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
dropdownColor: AppTheme.primaryBackground,
|
||||
onChanged: (value) => selectedRole = value ?? 'member',
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Role',
|
||||
labelStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppTheme.accentColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ModernGlassButton(
|
||||
onPressed: () {
|
||||
if (_canManage) {
|
||||
final username = usernameController.text.trim();
|
||||
if (username.isNotEmpty) {
|
||||
_inviteUser(username, selectedRole);
|
||||
usernameController.clear();
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'You do not have permission to send invitations',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Send Invitation'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_userSuggestions.isNotEmpty)
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
offset: const Offset(0, 48),
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListView.builder(
|
||||
itemCount: _userSuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = _userSuggestions[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
user.displayName ?? user.username,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
onTap: () {
|
||||
usernameController.text = user.username;
|
||||
setState(() => _userSuggestions = []);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequestsTab() {
|
||||
return ListView.builder(
|
||||
itemCount: _joinRequests.length,
|
||||
itemBuilder: (context, index) {
|
||||
final req = _joinRequests[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
req.user.displayName ?? req.user.username,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Requested to join',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
trailing: _canManage
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
style: ButtonStyle(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.pressed)) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
onPressed: () => _acceptJoinRequest(req.id, 'member'),
|
||||
child: const Text('Accept'),
|
||||
),
|
||||
TextButton(
|
||||
style: ButtonStyle(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.pressed)) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
onPressed: () => _rejectJoinRequest(req.id),
|
||||
child: Text(
|
||||
'Reject',
|
||||
style: TextStyle(color: AppTheme.errorColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
273
b0esche_cloud/lib/widgets/share_file_dialog.dart
Normal file
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../models/api_error.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import '../injection.dart';
|
||||
|
||||
class ShareFileDialog extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final String fileName;
|
||||
|
||||
const ShareFileDialog({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShareFileDialog> createState() => _ShareFileDialogState();
|
||||
}
|
||||
|
||||
class _ShareFileDialogState extends State<ShareFileDialog> {
|
||||
bool _isLoading = true;
|
||||
String? _shareUrl;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadShareLink();
|
||||
}
|
||||
|
||||
Future<void> _loadShareLink() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final path = widget.orgId.isEmpty || widget.orgId == 'personal'
|
||||
? '/orgs/files/${widget.fileId}/share'
|
||||
: '/orgs/${widget.orgId}/files/${widget.fileId}/share';
|
||||
final response = await apiClient.getRaw(path);
|
||||
|
||||
setState(() {
|
||||
_shareUrl = response['shareUrl'];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e is ApiError && e.status == 404) {
|
||||
// No link exists, create one automatically
|
||||
setState(() {
|
||||
_isLoading = true; // Keep loading for creation
|
||||
});
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final path = widget.orgId.isEmpty || widget.orgId == 'personal'
|
||||
? '/orgs/files/${widget.fileId}/share'
|
||||
: '/orgs/${widget.orgId}/files/${widget.fileId}/share';
|
||||
final response = await apiClient.postRaw(path, data: {});
|
||||
|
||||
setState(() {
|
||||
_shareUrl = response['shareUrl'];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (createError) {
|
||||
setState(() {
|
||||
_error = createError is ApiError
|
||||
? createError.message
|
||||
: 'Failed to create share link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = 'Failed to load share link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createShareLink() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final path = widget.orgId.isEmpty || widget.orgId == 'personal'
|
||||
? '/orgs/files/${widget.fileId}/share'
|
||||
: '/orgs/${widget.orgId}/files/${widget.fileId}/share';
|
||||
final response = await apiClient.postRaw(path, data: {});
|
||||
|
||||
setState(() {
|
||||
_shareUrl = response['shareUrl'];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e is ApiError ? e.message : 'Failed to create share link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard() {
|
||||
if (_shareUrl != null) {
|
||||
Clipboard.setData(ClipboardData(text: _shareUrl!));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Copied!')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: 500,
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Share "${widget.fileName}"',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: AppTheme.secondaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_error != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: AppTheme.errorColor),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: ModernGlassButton(
|
||||
onPressed: _loadShareLink,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_shareUrl != null) ...[
|
||||
Text(
|
||||
'Anyone with this link can view and download the file.',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: _shareUrl),
|
||||
readOnly: true,
|
||||
maxLines: 1,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.secondaryText.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.secondaryText.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: _copyToClipboard,
|
||||
child: const Icon(Icons.content_copy),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
] else ...[
|
||||
Text(
|
||||
'No share link yet. Create a public, read-only link for this file.',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: _createShareLink,
|
||||
isLoading: _isLoading,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('Create link'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
b0esche_cloud/lib/widgets/web_audio_player.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import '../services/api_client.dart';
|
||||
import '../injection.dart';
|
||||
|
||||
class AudioPlayer {
|
||||
web.HTMLAudioElement? _audioElement;
|
||||
String? _blobUrl;
|
||||
final StreamController<Duration> _positionController =
|
||||
StreamController<Duration>.broadcast();
|
||||
final StreamController<Duration> _durationController =
|
||||
StreamController<Duration>.broadcast();
|
||||
final StreamController<bool> _playingController =
|
||||
StreamController<bool>.broadcast();
|
||||
final StreamController<String> _errorController =
|
||||
StreamController<String>.broadcast();
|
||||
|
||||
// Store subscriptions for cleanup
|
||||
StreamSubscription? _durationSubscription;
|
||||
StreamSubscription? _positionSubscription;
|
||||
StreamSubscription? _playSubscription;
|
||||
StreamSubscription? _pauseSubscription;
|
||||
StreamSubscription? _endedSubscription;
|
||||
StreamSubscription? _errorSubscription;
|
||||
|
||||
Stream<Duration> get positionStream => _positionController.stream;
|
||||
Stream<Duration> get durationStream => _durationController.stream;
|
||||
Stream<bool> get playingStream => _playingController.stream;
|
||||
Stream<String> get errorStream => _errorController.stream;
|
||||
|
||||
void _disposeSubscriptions() {
|
||||
_durationSubscription?.cancel();
|
||||
_positionSubscription?.cancel();
|
||||
_playSubscription?.cancel();
|
||||
_pauseSubscription?.cancel();
|
||||
_endedSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
|
||||
_durationSubscription = null;
|
||||
_positionSubscription = null;
|
||||
_playSubscription = null;
|
||||
_pauseSubscription = null;
|
||||
_endedSubscription = null;
|
||||
_errorSubscription = null;
|
||||
}
|
||||
|
||||
Future<void> setUrl(String url, {String? mimeType}) async {
|
||||
// Clean up any existing subscriptions
|
||||
_disposeSubscriptions();
|
||||
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final path = url.replaceFirst(apiClient.baseUrl, '');
|
||||
final bytes = await apiClient.getBytes(path);
|
||||
final blob = web.Blob(
|
||||
[Uint8List.fromList(bytes)] as dynamic,
|
||||
web.BlobPropertyBag(type: mimeType ?? 'audio/mpeg'),
|
||||
);
|
||||
final blobUrl = web.URL.createObjectURL(blob);
|
||||
|
||||
_audioElement = web.HTMLAudioElement();
|
||||
_audioElement!.src = blobUrl;
|
||||
_audioElement!.crossOrigin = 'anonymous'; // Handle CORS
|
||||
|
||||
// Set up event listeners and store subscriptions
|
||||
_durationSubscription = _audioElement!.onLoadedMetadata.listen((_) {
|
||||
if (_audioElement != null) {
|
||||
_durationController.add(
|
||||
Duration(milliseconds: (_audioElement!.duration * 1000).toInt()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_positionSubscription = _audioElement!.onTimeUpdate.listen((_) {
|
||||
if (_audioElement != null) {
|
||||
_positionController.add(
|
||||
Duration(milliseconds: (_audioElement!.currentTime * 1000).toInt()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_playSubscription = _audioElement!.onPlay.listen((_) {
|
||||
_playingController.add(true);
|
||||
});
|
||||
|
||||
_pauseSubscription = _audioElement!.onPause.listen((_) {
|
||||
_playingController.add(false);
|
||||
});
|
||||
|
||||
_endedSubscription = _audioElement!.onEnded.listen((_) {
|
||||
_playingController.add(false);
|
||||
});
|
||||
|
||||
_errorSubscription = _audioElement!.onError.listen((_) {
|
||||
_errorController.add('Failed to load audio');
|
||||
});
|
||||
|
||||
// Load the audio
|
||||
_audioElement!.load();
|
||||
} catch (e) {
|
||||
_errorController.add('Error initializing audio: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> play() async {
|
||||
try {
|
||||
if (_audioElement != null) {
|
||||
// The play() method returns a JSPromise, but we can call it without await
|
||||
// since we're not depending on the promise resolution for our logic
|
||||
_audioElement!.play();
|
||||
}
|
||||
} catch (e) {
|
||||
_errorController.add('Error playing audio: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
_audioElement?.pause();
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
if (_audioElement != null) {
|
||||
_audioElement!.currentTime = position.inMilliseconds / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposeSubscriptions();
|
||||
_audioElement?.pause();
|
||||
if (_blobUrl != null) {
|
||||
web.URL.revokeObjectURL(_blobUrl!);
|
||||
_blobUrl = null;
|
||||
}
|
||||
_audioElement = null;
|
||||
_positionController.close();
|
||||
_durationController.close();
|
||||
_playingController.close();
|
||||
_errorController.close();
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,34 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_session
|
||||
import connectivity_plus
|
||||
import desktop_drop
|
||||
import device_info_plus
|
||||
import file_picker
|
||||
import flutter_secure_storage_darwin
|
||||
import irondash_engine_context
|
||||
import path_provider_foundation
|
||||
import just_audio
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import super_native_extensions
|
||||
import syncfusion_pdfviewer_macos
|
||||
import url_launcher_macos
|
||||
import video_player_avfoundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
|
||||
SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "67.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,94 +25,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_session:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
bloc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bloc
|
||||
sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d
|
||||
sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.0"
|
||||
bloc_test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: bloc_test
|
||||
sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.13"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.2"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.1"
|
||||
version: "9.2.0"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -153,22 +73,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -177,14 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -217,22 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+1"
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -241,22 +137,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dart_style:
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.6"
|
||||
version: "1.0.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.12"
|
||||
desktop_drop:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -281,22 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
diff_match_patch:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: diff_match_patch
|
||||
sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
version: "5.9.1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -309,26 +197,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
version: "2.0.8"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.1.5"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -341,10 +221,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200"
|
||||
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.7"
|
||||
version: "10.3.10"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -402,10 +282,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "5.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -478,24 +358,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
get_it:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -516,18 +383,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
|
||||
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.0.1"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
version: "17.1.0"
|
||||
hive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -544,38 +403,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
hive_generator:
|
||||
dependency: "direct dev"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hive_generator
|
||||
sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4"
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
http:
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.7.2"
|
||||
infinite_scroll_pagination:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -588,18 +455,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: injectable
|
||||
sha256: "8fc24421cfeff76d1d38484d8b9617beeb54a58b6edfd002b10cc896b8b8f3fe"
|
||||
sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.1+2"
|
||||
injectable_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: injectable_generator
|
||||
sha256: af403d76c7b18b4217335e0075e950cd0579fd7f8d7bd47ee7c85ada31680ba1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
version: "2.7.1+4"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -608,14 +467,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
irondash_engine_context:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -632,62 +483,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
js:
|
||||
just_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio
|
||||
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.5"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
name: just_audio_platform_interface
|
||||
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
version: "4.6.0"
|
||||
just_audio_web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
name: just_audio_web
|
||||
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.8.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "0.4.16"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "5.1.1"
|
||||
logger:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -704,14 +531,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -736,22 +555,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mockito:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mockito
|
||||
sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.4"
|
||||
mocktail:
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "0.17.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -768,14 +579,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "9.3.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -784,14 +595,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -828,10 +631,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.1"
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -888,14 +691,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
version: "6.0.3"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -912,22 +715,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -948,10 +735,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.18"
|
||||
version: "2.4.20"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -992,38 +779,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -1037,46 +792,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.12"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.5"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.13"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1117,30 +840,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1166,7 +865,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
syncfusion_flutter_core:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: syncfusion_flutter_core
|
||||
sha256: e1fdfcc3ed7e1f040ba95838780b2eb1857e3e5eccb817fbe94ea2b09c35eac4
|
||||
@@ -1253,38 +952,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.26.3"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.12"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1302,7 +969,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
@@ -1353,10 +1020,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1393,10 +1060,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
||||
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
version: "1.1.20"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1405,54 +1072,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
name: video_player
|
||||
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
version: "2.10.1"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
||||
name: video_player_android
|
||||
sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "2.9.1"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: f46e9e20f1fe429760cf4dc118761336320d1bec0f50d255930c2355f2defb5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.1"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.0"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1495,4 +1162,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.4 <4.0.0"
|
||||
flutter: ">=3.35.1"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: b0esche_cloud
|
||||
description: "A new Flutter project."
|
||||
name: b0esche
|
||||
description: "b0esche secure cloud"
|
||||
publish_to: "none"
|
||||
version: 0.1.0
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies:
|
||||
|
||||
# Networking
|
||||
dio: ^5.3.2
|
||||
http_parser: ^4.0.2
|
||||
|
||||
# Routing
|
||||
go_router: ^17.0.1
|
||||
@@ -48,6 +49,7 @@ dependencies:
|
||||
path_provider: ^2.1.2
|
||||
connectivity_plus: ^7.0.0
|
||||
provider: ^6.1.1
|
||||
url_launcher: ^6.2.2
|
||||
file_picker: ^10.3.7
|
||||
flutter_dropzone: ^4.0.0
|
||||
desktop_drop: ^0.7.0
|
||||
@@ -55,27 +57,28 @@ dependencies:
|
||||
infinite_scroll_pagination: ^5.1.1
|
||||
collection: ^1.18.0
|
||||
syncfusion_flutter_pdfviewer: ^31.1.21
|
||||
web: ^1.1.0
|
||||
http: ^1.2.0
|
||||
archive: ^4.0.4
|
||||
|
||||
# Video Playback
|
||||
video_player: ^2.8.2
|
||||
syncfusion_flutter_core: ^31.2.18
|
||||
just_audio_web: ^0.4.16
|
||||
just_audio: ^0.10.5
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
image: ^4.7.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# Code Generation
|
||||
build_runner: ^2.4.6
|
||||
json_serializable: ^6.7.1
|
||||
injectable_generator: ^2.4.1
|
||||
hive_generator: ^2.0.1
|
||||
|
||||
# Testing
|
||||
mockito: ^5.4.4
|
||||
bloc_test: ^10.0.0
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/fonts/
|
||||
- assets/icons/.
|
||||
|
||||
fonts:
|
||||
- family: PixelatedElegance
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_bloc.dart';
|
||||
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_event.dart';
|
||||
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_state.dart';
|
||||
import 'package:b0esche_cloud/services/file_service.dart';
|
||||
import 'package:b0esche_cloud/models/viewer_session.dart';
|
||||
import 'package:b0esche_cloud/models/document_capabilities.dart';
|
||||
import 'package:b0esche_cloud/models/api_error.dart';
|
||||
|
||||
class MockFileService extends Mock implements FileService {
|
||||
Future<ViewerSession>? _viewerResponse;
|
||||
|
||||
void setViewerResponse(Future<ViewerSession> response) {
|
||||
_viewerResponse = response;
|
||||
}
|
||||
|
||||
void resetMock() {
|
||||
_viewerResponse = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
||||
return _viewerResponse ??
|
||||
super.noSuchMethod(
|
||||
Invocation.method(#requestViewerSession, [orgId, fileId]),
|
||||
returnValue: Future.value(null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// @override
|
||||
// Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
||||
// return _viewerResponse ??
|
||||
// super.noSuchMethod(
|
||||
// Invocation.method(#requestViewerSession, [orgId, fileId]),
|
||||
// returnValue: Future.value(null),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
void main() {
|
||||
late MockFileService mockFileService;
|
||||
|
||||
setUp(() {
|
||||
mockFileService = MockFileService();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
reset(mockFileService);
|
||||
mockFileService.resetMock();
|
||||
});
|
||||
|
||||
group('DocumentViewerBloc', () {
|
||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||
'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails',
|
||||
build: () {
|
||||
mockFileService.setViewerResponse(
|
||||
Future.error(
|
||||
ApiError(
|
||||
code: 'server_error',
|
||||
message: 'Server error',
|
||||
status: 500,
|
||||
),
|
||||
),
|
||||
);
|
||||
return DocumentViewerBloc(mockFileService);
|
||||
},
|
||||
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
|
||||
expect: () => [
|
||||
DocumentViewerLoading(),
|
||||
DocumentViewerReady(
|
||||
viewUrl: Uri.parse('https://example.com/view'),
|
||||
caps: DocumentCapabilities(
|
||||
canEdit: true,
|
||||
canAnnotate: false,
|
||||
isPdf: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||
'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds',
|
||||
build: () {
|
||||
mockFileService.setViewerResponse(
|
||||
Future.value(
|
||||
ViewerSession(
|
||||
viewUrl: Uri.parse('https://example.com/view'),
|
||||
capabilities: DocumentCapabilities(
|
||||
canEdit: true,
|
||||
canAnnotate: false,
|
||||
isPdf: false,
|
||||
),
|
||||
token: 'mock-token',
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return DocumentViewerBloc(mockFileService);
|
||||
},
|
||||
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
|
||||
expect: () => [
|
||||
DocumentViewerLoading(),
|
||||
DocumentViewerError(message: 'Failed to open document: Server error'),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||
'emits [DocumentViewerInitial] when DocumentClosed',
|
||||
build: () => DocumentViewerBloc(mockFileService),
|
||||
act: (bloc) => bloc.add(DocumentClosed()),
|
||||
expect: () => [DocumentViewerInitial()],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 1008 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 147 KiB |
@@ -19,7 +19,7 @@
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta name="description" content="b0esche secure cloud">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -30,31 +30,42 @@
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
|
||||
<!-- Preload fonts -->
|
||||
<link rel="preload" href="assets/fonts/veteran-typewriter/veteran_typewriter.ttf" as="font" type="font/ttf"
|
||||
crossorigin>
|
||||
<link rel="preload" href="assets/fonts/animal-park/animal_park.otf" as="font" type="font/otf" crossorigin>
|
||||
<link rel="preload" href="assets/fonts/renoire-demo/renoire_demo.otf" as="font" type="font/otf" crossorigin>
|
||||
<!-- Preload PixelatedElegance brand font -->
|
||||
<link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font"
|
||||
type="font/ttf" crossorigin>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'VeteranTypewriter';
|
||||
src: url('assets/fonts/veteran-typewriter/veteran_typewriter.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'AnimalPark';
|
||||
src: url('assets/fonts/animal-park/animal_park.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RenoireDemo';
|
||||
src: url('assets/fonts/renoire-demo/renoire_demo.otf') format('opentype');
|
||||
font-family: 'PixelatedElegance';
|
||||
src: url('assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf') format('truetype');
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>b0esche_cloud</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<!-- PDF.js library for SfPdfViewer on web - loaded asynchronously to avoid sync XHR warnings -->
|
||||
<script type="module">
|
||||
(async () => {
|
||||
const pdfjsLib = await import('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs');
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs";
|
||||
window.pdfjsLib = pdfjsLib;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Suppress v8BreakIterator deprecation warning (Flutter framework issue) -->
|
||||
<script>
|
||||
// This is a known Flutter web issue - the framework uses v8BreakIterator for feature detection
|
||||
// The warning can be safely ignored as Flutter handles the fallback to Intl.Segmenter internally
|
||||
// See: https://github.com/nickvds/my-public/issues
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function (...args) {
|
||||
if (args[0] && typeof args[0] === 'string' && args[0].includes('v8BreakIterator')) {
|
||||
return; // Suppress this specific warning
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "b0esche_cloud",
|
||||
"short_name": "b0esche_cloud",
|
||||
"name": "b0esche.cloud",
|
||||
"short_name": "b0esche",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"description": "b0esche secure cloud",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
@@ -32,4 +32,4 @@
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(b0esche_cloud LANGUAGES CXX)
|
||||
project(b0esche.cloud LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
|
||||
596
docs/API.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# b0esche.cloud API Reference
|
||||
|
||||
Base URL: `https://go.b0esche.cloud`
|
||||
|
||||
## Authentication
|
||||
|
||||
All authenticated endpoints require a JWT token in the Authorization header:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
||||
### GET /health
|
||||
Check if the API is running.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2026-01-13T19:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Endpoints
|
||||
|
||||
### Passkey Registration
|
||||
|
||||
#### POST /auth/passkey/register/start
|
||||
Start passkey registration for a new user.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "johndoe"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"publicKey": {
|
||||
"challenge": "base64-encoded-challenge",
|
||||
"rp": {
|
||||
"name": "b0esche.cloud",
|
||||
"id": "www.b0esche.cloud"
|
||||
},
|
||||
"user": {
|
||||
"id": "base64-user-id",
|
||||
"name": "johndoe",
|
||||
"displayName": "johndoe"
|
||||
},
|
||||
"pubKeyCredParams": [...],
|
||||
"timeout": 300000,
|
||||
"attestation": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /auth/passkey/register/verify
|
||||
Complete passkey registration.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "johndoe",
|
||||
"credential": {
|
||||
"id": "credential-id",
|
||||
"rawId": "base64-raw-id",
|
||||
"type": "public-key",
|
||||
"response": {
|
||||
"clientDataJSON": "base64-client-data",
|
||||
"attestationObject": "base64-attestation"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "johndoe"
|
||||
},
|
||||
"token": "jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
### Passkey Login
|
||||
|
||||
#### POST /auth/passkey/login/start
|
||||
Start passkey authentication.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "johndoe"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"publicKey": {
|
||||
"challenge": "base64-challenge",
|
||||
"timeout": 300000,
|
||||
"rpId": "www.b0esche.cloud",
|
||||
"allowCredentials": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /auth/passkey/login/verify
|
||||
Complete passkey authentication.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "johndoe",
|
||||
"credential": {
|
||||
"id": "credential-id",
|
||||
"rawId": "base64-raw-id",
|
||||
"type": "public-key",
|
||||
"response": {
|
||||
"clientDataJSON": "base64-client-data",
|
||||
"authenticatorData": "base64-auth-data",
|
||||
"signature": "base64-signature"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "johndoe",
|
||||
"role": "user"
|
||||
},
|
||||
"token": "jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
### Device Management
|
||||
|
||||
#### GET /auth/passkey/devices
|
||||
List user's registered passkeys.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"credentialId": "credential-id",
|
||||
"deviceLabel": "MacBook Pro",
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"lastUsedAt": "2026-01-13T19:00:00Z",
|
||||
"backupEligible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /auth/passkey/devices/add
|
||||
Add a new passkey to existing account.
|
||||
|
||||
#### DELETE /auth/passkey/devices/{passkeyId}
|
||||
Remove a passkey from account.
|
||||
|
||||
### Recovery Codes
|
||||
|
||||
#### POST /auth/recovery/codes/generate
|
||||
Generate new recovery codes (invalidates old ones).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"codes": [
|
||||
"XXXX-XXXX-XXXX",
|
||||
"YYYY-YYYY-YYYY",
|
||||
...
|
||||
],
|
||||
"expiresAt": "2027-01-13T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /auth/recovery/codes/use
|
||||
Use a recovery code to authenticate.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "johndoe",
|
||||
"code": "XXXX-XXXX-XXXX"
|
||||
}
|
||||
```
|
||||
|
||||
### Password (Optional Fallback)
|
||||
|
||||
#### POST /auth/password/add
|
||||
Add password to account.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"password": "secure-password"
|
||||
}
|
||||
```
|
||||
|
||||
#### DELETE /auth/password/remove
|
||||
Remove password from account.
|
||||
|
||||
---
|
||||
|
||||
## User Endpoints
|
||||
|
||||
### GET /api/me
|
||||
Get current user profile.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com",
|
||||
"displayName": "John Doe",
|
||||
"role": "user",
|
||||
"createdAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH /api/me
|
||||
Update user profile.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"displayName": "John D.",
|
||||
"email": "newemail@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Organization Endpoints
|
||||
|
||||
### GET /api/organizations
|
||||
List user's organizations.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Team",
|
||||
"slug": "my-team",
|
||||
"role": "owner",
|
||||
"memberCount": 5,
|
||||
"createdAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/organizations
|
||||
Create a new organization.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "My New Team",
|
||||
"slug": "my-new-team"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/organizations/{orgId}
|
||||
Get organization details.
|
||||
|
||||
### PATCH /api/organizations/{orgId}
|
||||
Update organization.
|
||||
|
||||
### DELETE /api/organizations/{orgId}
|
||||
Delete organization (owner only).
|
||||
|
||||
### GET /api/organizations/{orgId}/members
|
||||
List organization members.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"members": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"userId": "user-uuid",
|
||||
"username": "johndoe",
|
||||
"displayName": "John Doe",
|
||||
"role": "owner",
|
||||
"joinedAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/organizations/{orgId}/members
|
||||
Add member to organization.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"userId": "user-uuid",
|
||||
"role": "member"
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH /api/organizations/{orgId}/members/{memberId}
|
||||
Update member role.
|
||||
|
||||
### DELETE /api/organizations/{orgId}/members/{memberId}
|
||||
Remove member from organization.
|
||||
|
||||
---
|
||||
|
||||
## File Endpoints
|
||||
|
||||
### GET /api/files
|
||||
List files in a directory.
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `path` | string | Directory path (default: `/`) |
|
||||
| `orgId` | string | Organization ID (optional) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "document.pdf",
|
||||
"path": "/documents/document.pdf",
|
||||
"type": "file",
|
||||
"mimeType": "application/pdf",
|
||||
"size": 1048576,
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"modifiedAt": "2026-01-13T19:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "photos",
|
||||
"path": "/photos",
|
||||
"type": "folder",
|
||||
"createdAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/files/upload
|
||||
Upload a file.
|
||||
|
||||
**Request:** `multipart/form-data`
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `file` | file | The file to upload |
|
||||
| `path` | string | Destination path |
|
||||
| `orgId` | string | Organization ID (optional) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"file": {
|
||||
"id": "uuid",
|
||||
"name": "uploaded-file.pdf",
|
||||
"path": "/documents/uploaded-file.pdf",
|
||||
"size": 1048576
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/files/download
|
||||
Download a file.
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `path` | string | File path |
|
||||
| `orgId` | string | Organization ID (optional) |
|
||||
|
||||
**Response:** File binary with appropriate Content-Type header.
|
||||
|
||||
### POST /api/files/folder
|
||||
Create a folder.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"path": "/new-folder",
|
||||
"orgId": "org-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/files
|
||||
Delete a file or folder.
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `path` | string | Path to delete |
|
||||
| `orgId` | string | Organization ID (optional) |
|
||||
|
||||
### POST /api/files/move
|
||||
Move/rename a file or folder.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"sourcePath": "/old-name.pdf",
|
||||
"destinationPath": "/new-name.pdf",
|
||||
"orgId": "org-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/files/copy
|
||||
Copy a file or folder.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"sourcePath": "/original.pdf",
|
||||
"destinationPath": "/copy.pdf",
|
||||
"orgId": "org-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin Endpoints
|
||||
|
||||
*Requires admin or superadmin role.*
|
||||
|
||||
### GET /api/admin/users
|
||||
List all users.
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `page` | int | Page number (default: 1) |
|
||||
| `limit` | int | Items per page (default: 50) |
|
||||
| `search` | string | Search by username/email |
|
||||
|
||||
### GET /api/admin/users/{userId}
|
||||
Get user details.
|
||||
|
||||
### PATCH /api/admin/users/{userId}
|
||||
Update user (role, status).
|
||||
|
||||
### DELETE /api/admin/users/{userId}
|
||||
Delete user account.
|
||||
|
||||
### Admin Invitations
|
||||
|
||||
#### GET /auth/admin/invitations
|
||||
List admin invitations.
|
||||
|
||||
#### POST /auth/admin/invitations
|
||||
Create admin invitation.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "newadmin",
|
||||
"roleId": "admin-role-uuid",
|
||||
"expiresIn": 86400
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"invitation": {
|
||||
"id": "uuid",
|
||||
"token": "invite-token",
|
||||
"expiresAt": "2026-01-14T19:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /auth/admin/invitations/{token}/accept
|
||||
Accept an admin invitation.
|
||||
|
||||
#### DELETE /auth/admin/invitations/{token}
|
||||
Revoke an invitation.
|
||||
|
||||
---
|
||||
|
||||
## Activity Endpoints
|
||||
|
||||
### GET /api/activities
|
||||
Get activity log.
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `page` | int | Page number |
|
||||
| `limit` | int | Items per page |
|
||||
| `orgId` | string | Filter by organization |
|
||||
| `userId` | string | Filter by user |
|
||||
| `action` | string | Filter by action type |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"userId": "user-uuid",
|
||||
"username": "johndoe",
|
||||
"action": "file.upload",
|
||||
"resourceType": "file",
|
||||
"resourceId": "/documents/report.pdf",
|
||||
"metadata": {
|
||||
"size": 1048576
|
||||
},
|
||||
"createdAt": "2026-01-13T19:00:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All errors follow this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable error message",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
|------|-------------|-------------|
|
||||
| `UNAUTHORIZED` | 401 | Missing or invalid token |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `VALIDATION_ERROR` | 400 | Invalid request data |
|
||||
| `CONFLICT` | 409 | Resource already exists |
|
||||
| `INTERNAL_ERROR` | 500 | Server error |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **Authentication endpoints**: 10 requests/minute
|
||||
- **API endpoints**: 100 requests/minute
|
||||
- **File uploads**: 50 requests/hour
|
||||
|
||||
Rate limit headers:
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 95
|
||||
X-RateLimit-Reset: 1705172400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhooks (Future)
|
||||
|
||||
Planned webhook events:
|
||||
- `user.created`
|
||||
- `user.deleted`
|
||||
- `file.uploaded`
|
||||
- `file.deleted`
|
||||
- `org.member.added`
|
||||
- `org.member.removed`
|
||||
367
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# b0esche.cloud Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
b0esche.cloud is a self-hosted cloud storage platform inspired by Google Workspace, built with a modern microservices-style architecture.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Internet │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Traefik Reverse Proxy │
|
||||
│ (SSL Termination, Routing, Load Balancing) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ www.* │ │ go.* │ │ storage.* │ │ of.* │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
└─────────┼────────────────┼────────────────┼────────────────┼────────────────────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Flutter Web │ │ Go Backend │ │ Nextcloud │ │ Collabora │
|
||||
│ (Nginx) │ │ (API) │ │ (Storage) │ │ (Office) │
|
||||
└──────────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ PostgreSQL │
|
||||
│ (Database) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Flutter Web Frontend (`b0esche_cloud/`)
|
||||
|
||||
The user-facing web application built with Flutter.
|
||||
|
||||
**Technology Stack:**
|
||||
- Flutter 3.x with Dart
|
||||
- BLoC pattern for state management
|
||||
- Material Design 3 theming
|
||||
|
||||
**Key Modules:**
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `blocs/` | Business logic components (auth, files, orgs) |
|
||||
| `models/` | Data models (User, File, Organization) |
|
||||
| `pages/` | UI screens (Home, Files, Settings, Admin) |
|
||||
| `repositories/` | Data access layer |
|
||||
| `services/` | API client, WebAuthn service |
|
||||
| `widgets/` | Reusable UI components |
|
||||
|
||||
**State Management Flow:**
|
||||
```
|
||||
User Action → BLoC Event → BLoC Logic → State Update → UI Rebuild
|
||||
↓
|
||||
Repository
|
||||
↓
|
||||
API Service
|
||||
↓
|
||||
Go Backend
|
||||
```
|
||||
|
||||
### 2. Go Backend (`go_cloud/`)
|
||||
|
||||
The API server handling business logic, authentication, and service orchestration.
|
||||
|
||||
**Technology Stack:**
|
||||
- Go 1.21+
|
||||
- Chi Router for HTTP routing
|
||||
- sqlx for database access
|
||||
- go-webauthn for passkey authentication
|
||||
|
||||
**Key Packages:**
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `internal/auth/` | Authentication (OIDC, Passkeys, Sessions) |
|
||||
| `internal/files/` | File metadata and operations |
|
||||
| `internal/org/` | Organization and membership management |
|
||||
| `internal/storage/` | Nextcloud/WebDAV integration |
|
||||
| `internal/http/` | HTTP handlers and WOPI endpoints |
|
||||
| `internal/middleware/` | Auth, logging, CORS middleware |
|
||||
| `pkg/jwt/` | JWT token utilities |
|
||||
|
||||
**Request Flow:**
|
||||
```
|
||||
HTTP Request → Traefik → Chi Router → Middleware → Handler → Service → Response
|
||||
↓
|
||||
Database/Storage
|
||||
```
|
||||
|
||||
### 3. PostgreSQL Database
|
||||
|
||||
Stores application metadata (not files).
|
||||
|
||||
**Key Tables:**
|
||||
- `users` - User accounts and profiles
|
||||
- `roles` - Permission roles (user, admin, superadmin)
|
||||
- `passkeys` - WebAuthn credentials
|
||||
- `organizations` - Org definitions
|
||||
- `org_memberships` - User-org relationships
|
||||
- `activities` - Audit log
|
||||
|
||||
**Schema Relationships:**
|
||||
```
|
||||
users ──┬── passkeys (1:N)
|
||||
├── org_memberships (N:M) ── organizations
|
||||
├── recovery_codes (1:N)
|
||||
└── activities (1:N)
|
||||
```
|
||||
|
||||
### 4. Nextcloud (Storage)
|
||||
|
||||
File storage backend and OIDC provider.
|
||||
|
||||
**Responsibilities:**
|
||||
- File storage via WebDAV
|
||||
- User authentication (OIDC)
|
||||
- File sharing capabilities
|
||||
- Version control
|
||||
|
||||
**Integration Points:**
|
||||
- WebDAV API for file operations
|
||||
- OIDC for authentication
|
||||
- User provisioning sync
|
||||
|
||||
### 5. Collabora Online (Office)
|
||||
|
||||
Document editing service for Office files.
|
||||
|
||||
**Supported Formats:**
|
||||
- Documents: DOCX, ODT, RTF
|
||||
- Spreadsheets: XLSX, ODS, CSV
|
||||
- Presentations: PPTX, ODP
|
||||
|
||||
**Integration:**
|
||||
- WOPI protocol for document access
|
||||
- Embedded iframe in Flutter app
|
||||
|
||||
### 6. Traefik (Reverse Proxy)
|
||||
|
||||
SSL termination and request routing.
|
||||
|
||||
**Features:**
|
||||
- Automatic SSL via Let's Encrypt (DNS-01 challenge)
|
||||
- Dynamic service discovery
|
||||
- Load balancing
|
||||
- Request routing based on hostname
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Client │───▶│ Frontend │───▶│ Backend │───▶│ Database │
|
||||
└────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ 1. Username + Passkey │
|
||||
│─────────────────────────────▶│
|
||||
│ │
|
||||
│ 2. WebAuthn Challenge │
|
||||
│◀─────────────────────────────│
|
||||
│ │
|
||||
│ 3. Signed Challenge │
|
||||
│─────────────────────────────▶│
|
||||
│ │ 4. Verify Signature
|
||||
│ │ 5. Create Session
|
||||
│ 6. JWT Token │
|
||||
│◀─────────────────────────────│
|
||||
```
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Passkeys (WebAuthn)**: Primary authentication method using FIDO2/U2F security keys
|
||||
- **JWT Tokens**: Session-based tokens with configurable expiration
|
||||
- **Role-Based Access Control (RBAC)**: Owner, Admin, Member roles for organizations
|
||||
- **Permission System**: Granular permissions for file operations (read, write, view, edit)
|
||||
|
||||
### Input Validation & Sanitization
|
||||
|
||||
- **Path Traversal Protection**: All file paths are sanitized to prevent directory traversal attacks
|
||||
- **UUID Validation**: All resource IDs (users, orgs, files) are validated as proper UUIDs
|
||||
- **JSON Schema Validation**: API inputs are validated for correct structure and types
|
||||
|
||||
### Network Security
|
||||
|
||||
- **HTTPS Only**: All external traffic is encrypted via TLS
|
||||
- **CORS Policy**: Restricted to allowed origins with credentials support
|
||||
- **Rate Limiting**: 100 requests/minute general, 10 requests/minute for auth endpoints
|
||||
- **Security Headers**:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY` (except for WOPI/Collabora)
|
||||
- `X-XSS-Protection: 1; mode=block`
|
||||
- `Content-Security-Policy`: Restrictive policy allowing only necessary sources
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Encrypted Storage**: Files stored encrypted in Nextcloud
|
||||
- **Secure Passwords**: Auto-generated secure passwords for Nextcloud user accounts
|
||||
- **Audit Logging**: All operations logged with user/org context
|
||||
- **No Secrets in Logs**: Sensitive data never logged
|
||||
|
||||
### API Security
|
||||
|
||||
- **Token Validation**: Every protected endpoint validates JWT tokens
|
||||
- **Session Management**: Secure session handling with database-backed validation
|
||||
- **Error Handling**: Safe error responses that don't leak internal details
|
||||
|
||||
### File Security
|
||||
|
||||
- **Scoped Access**: Users can only access files within their personal workspace or authorized organizations
|
||||
- **Share Tokens**: Public shares use short-lived, single-use tokens
|
||||
- **Nextcloud Integration**: Leverages Nextcloud's security features for file access
|
||||
|
||||
### Infrastructure Security
|
||||
|
||||
- **Container Security**: Docker images run as non-root where possible
|
||||
- **Network Isolation**: Internal Docker networks prevent direct external access
|
||||
- **Deployment Security**: Automated deployments with health checks
|
||||
|
||||
## Data Flow
|
||||
|
||||
### File Upload Flow
|
||||
|
||||
```
|
||||
┌────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐
|
||||
│ Client │───▶│ Frontend │───▶│ Backend │───▶│ Nextcloud │
|
||||
└────────┘ └──────────┘ └──────────┘ └───────────┘
|
||||
│ │ │
|
||||
│ 1. Select File │ │
|
||||
│─────────────────────────────▶│ │
|
||||
│ │ │
|
||||
│ │ 2. WebDAV PUT │
|
||||
│ │───────────────▶│
|
||||
│ │ │
|
||||
│ │ 3. Success │
|
||||
│ │◀───────────────│
|
||||
│ │ │
|
||||
│ │ 4. Save Metadata
|
||||
│ │ (PostgreSQL) │
|
||||
│ 5. Confirmation │ │
|
||||
│◀─────────────────────────────│ │
|
||||
```
|
||||
|
||||
## Network Architecture
|
||||
|
||||
### Docker Networks
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ proxy (172.20.0.0/16) │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ traefik │ │ flutter │ │ go │ │nextcloud│ │
|
||||
│ │ │ │ web │ │ backend │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └────┬────┘ └─────────┘ │
|
||||
└───────────────────────────────┼─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────┼─────────────────────────────┐
|
||||
│ backend (internal) │
|
||||
│ ┌────┴────┐ │
|
||||
│ │postgres │ │
|
||||
│ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Port Mapping
|
||||
|
||||
| Service | Internal Port | External |
|
||||
|---------|---------------|----------|
|
||||
| Traefik | 80, 443 | Exposed |
|
||||
| Flutter Web | 80 | Via Traefik |
|
||||
| Go Backend | 8080 | Via Traefik |
|
||||
| PostgreSQL | 5432 | Internal only |
|
||||
| Nextcloud | 80 | Via Traefik |
|
||||
| Collabora | 9980 | Via Traefik |
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication Layers
|
||||
|
||||
1. **Primary**: WebAuthn Passkeys (FIDO2)
|
||||
2. **Fallback**: Optional password authentication
|
||||
3. **Legacy**: OIDC via Nextcloud (deprecated)
|
||||
4. **Recovery**: One-time recovery codes
|
||||
|
||||
### Authorization Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Role Hierarchy │
|
||||
│ │
|
||||
│ superadmin (Level 3) │
|
||||
│ ├── All system access │
|
||||
│ ├── User management │
|
||||
│ └── Can manage admins │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ admin (Level 2) │
|
||||
│ ├── Organization management │
|
||||
│ ├── User role management (within orgs) │
|
||||
│ └── Activity monitoring │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ user (Level 1) │
|
||||
│ ├── Personal file management │
|
||||
│ ├── Organization membership │
|
||||
│ └── Basic settings │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Organization Roles
|
||||
|
||||
Within each organization:
|
||||
- **Owner**: Full control, can delete org
|
||||
- **Admin**: Can manage members and files
|
||||
- **Member**: Read/write access to shared files
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
### Current Architecture (Single Server)
|
||||
|
||||
- All services on one VPS
|
||||
- Suitable for small teams (< 100 users)
|
||||
- Simple deployment and maintenance
|
||||
|
||||
### Future Scaling Options
|
||||
|
||||
1. **Database**: Read replicas, connection pooling
|
||||
2. **Storage**: S3-compatible backends, CDN for static assets
|
||||
3. **Backend**: Horizontal scaling with load balancer
|
||||
4. **Frontend**: CDN distribution, edge caching
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Logging
|
||||
|
||||
- **Traefik**: Access logs, error logs
|
||||
- **Go Backend**: Structured JSON logs
|
||||
- **PostgreSQL**: Query logs, slow query analysis
|
||||
- **Docker**: Container logs via `docker logs`
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl https://go.b0esche.cloud/health
|
||||
|
||||
# Frontend availability
|
||||
curl -I https://www.b0esche.cloud
|
||||
|
||||
# Database connectivity
|
||||
docker exec go-postgres pg_isready
|
||||
```
|
||||
|
||||
### Metrics (Future)
|
||||
|
||||
- Prometheus for metrics collection
|
||||
- Grafana for visualization
|
||||
- AlertManager for alerting
|
||||
279
docs/AUTH.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# b0esche.cloud Authentication System
|
||||
|
||||
This document describes the complete passkey-first authentication and authorization system for b0esche.cloud.
|
||||
|
||||
## Overview
|
||||
|
||||
b0esche.cloud implements a modern, secure, username-only, passkey-first authentication system with comprehensive admin functionality and recovery options.
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Primary Authentication: Passkeys (WebAuthn)
|
||||
|
||||
- **Username + Passkey Registration**: Users create an account with just a username and register a passkey
|
||||
- **Passkey-First Login**: Primary authentication method using WebAuthn/FIDO2 standards
|
||||
- **Device Support**: Works with Touch ID, Windows Hello, YubiKey, and other FIDO2 authenticators
|
||||
- **Multiple Passkeys**: Users can register multiple devices for redundancy
|
||||
|
||||
### Fallback Options
|
||||
|
||||
- **Optional Password**: Users can add a password as a fallback authentication method
|
||||
- **Recovery Codes**: 10 single-use recovery codes generated per user
|
||||
- **Admin Recovery**: Admins can assist with account recovery if needed
|
||||
|
||||
## User Roles and Permissions
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
1. **superadmin** (Level 3): Full system access
|
||||
- User management and role assignments
|
||||
- Admin invitation system
|
||||
- System configuration access
|
||||
- All organization management
|
||||
|
||||
2. **admin** (Level 2): Administrative access
|
||||
- Organization management
|
||||
- User role promotion (within their orgs)
|
||||
- Activity monitoring
|
||||
- File management across organizations
|
||||
|
||||
3. **user** (Level 1): Standard user access
|
||||
- Personal file management
|
||||
- Organization membership
|
||||
- Basic account settings
|
||||
|
||||
## Bootstrap Process
|
||||
|
||||
### Creating the First Administrator
|
||||
|
||||
The system includes a bootstrap utility to create the first superadmin user securely:
|
||||
|
||||
```bash
|
||||
# Interactive mode (recommended for production)
|
||||
./bin/bootstrap
|
||||
|
||||
# Environment variable mode (recommended for automation)
|
||||
export BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
export BOOTSTRAP_ADMIN_PASSWORD=secure_password_123
|
||||
./bin/bootstrap
|
||||
```
|
||||
|
||||
#### Bootstrap Process
|
||||
|
||||
1. **Check Existing Admin**: Verifies no superadmin user already exists
|
||||
2. **Secure Input**: Prompts for username and password (or reads from environment)
|
||||
3. **Password Security**: Enforces minimum 8-character passwords
|
||||
4. **Role Assignment**: Automatically assigns superadmin role
|
||||
5. **Secure Storage**: Passwords are bcrypt-hashed before storage
|
||||
|
||||
#### Security Guidelines
|
||||
|
||||
- **Immediate Action**: Log in immediately after bootstrap to register a passkey
|
||||
- **Passkey Priority**: Set up passkeys and consider removing the password
|
||||
- **Recovery Setup**: Generate recovery codes during first login
|
||||
- **Credential Rotation**: Never leave default credentials in production
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
```
|
||||
POST /auth/passkey/register/start
|
||||
POST /auth/passkey/register/verify
|
||||
POST /auth/passkey/login/start
|
||||
POST /auth/passkey/login/verify
|
||||
```
|
||||
|
||||
### Device Management
|
||||
|
||||
```
|
||||
GET /auth/passkey/devices # List user's passkeys
|
||||
POST /auth/passkey/devices/add # Add new passkey
|
||||
DELETE /auth/passkey/devices/{id} # Remove passkey
|
||||
```
|
||||
|
||||
### Recovery System
|
||||
|
||||
```
|
||||
POST /auth/recovery/codes/generate # Generate recovery codes
|
||||
POST /auth/recovery/codes/use # Use recovery code
|
||||
DELETE /auth/recovery/codes/revoke # Revoke all codes
|
||||
```
|
||||
|
||||
### Admin Operations
|
||||
|
||||
```
|
||||
GET /auth/admin/invitations # List invitations
|
||||
POST /auth/admin/invitations # Create invitation
|
||||
POST /auth/admin/invitations/accept # Accept invitation
|
||||
DELETE /auth/admin/invitations/{id} # Revoke invitation
|
||||
```
|
||||
|
||||
### Password Fallback
|
||||
|
||||
```
|
||||
POST /auth/password/add # Add password to account
|
||||
DELETE /auth/password/remove # Remove password from account
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
SERVER_ADDR=:8080
|
||||
DATABASE_URL=postgresql://user:pass@localhost/db
|
||||
|
||||
# WebAuthn Configuration
|
||||
WEBAUTHN_RP_ID=www.b0esche.cloud
|
||||
WEBAUTHN_RP_NAME=b0esche.cloud
|
||||
WEBAUTHN_RP_ORIGIN=https://www.b0esche.cloud
|
||||
|
||||
# Security Configuration
|
||||
JWT_SECRET=your_jwt_secret_key
|
||||
PASSKEY_TIMEOUT=300000
|
||||
RECOVERY_CODE_EXPIRY=86400
|
||||
MAX_RECOVERY_CODES=10
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### WebAuthn Security
|
||||
|
||||
- **FIDO2/WebAuthn Compliant**: Full compliance with WebAuthn Level 2
|
||||
- **Origin Binding**: Credentials bound to specific domain
|
||||
- **Challenge-Response**: Cryptographic challenge verification
|
||||
- **Device Attestation**: Optional device verification
|
||||
- **Public Key Crypto**: Asymmetric cryptography with private keys never leaving devices
|
||||
|
||||
### Account Security
|
||||
|
||||
- **Rate Limiting**: Built-in protection against brute force attacks
|
||||
- **Session Management**: Secure HTTP-only sessions with configurable expiration
|
||||
- **Audit Logging**: Comprehensive logging of all authentication and admin actions
|
||||
- **Role-Based Access Control**: Hierarchical permission system
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Password Hashing**: bcrypt with automatic salt generation
|
||||
- **Recovery Code Hashing**: Secure one-way hashing of recovery codes
|
||||
- **No Plaintext Storage**: No sensitive data stored in plaintext
|
||||
- **Input Validation**: Comprehensive input sanitization and validation
|
||||
|
||||
## Database Schema
|
||||
|
||||
The authentication system uses the following key tables:
|
||||
|
||||
- **users**: User accounts with role-based access control
|
||||
- **roles**: Hierarchical role definitions
|
||||
- **passkeys**: WebAuthn credential storage
|
||||
- **recovery_codes**: One-time recovery codes
|
||||
- **admin_invitations**: Secure admin invitation system
|
||||
- **sessions**: Secure session management
|
||||
|
||||
## Development and Testing
|
||||
|
||||
### Running Locally
|
||||
|
||||
1. **Setup Database**: Ensure PostgreSQL is running with migrations applied
|
||||
2. **Bootstrap Admin**: Run the bootstrap command to create first admin
|
||||
3. **Start Server**: Run the API server with appropriate environment
|
||||
4. **Test Authentication**: Use the Flutter app or API tests
|
||||
|
||||
### Testing WebAuthn
|
||||
|
||||
WebAuthn requires HTTPS in production browsers. For local testing:
|
||||
|
||||
```bash
|
||||
# Use mkcert for local HTTPS
|
||||
mkcert -install
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
|
||||
# Set environment for local development
|
||||
export WEBAUTHN_RP_ID=localhost
|
||||
export WEBAUTHN_RP_ORIGIN=https://localhost:8080
|
||||
```
|
||||
|
||||
## User Experience
|
||||
|
||||
### Signup Flow
|
||||
|
||||
1. **Username Selection**: User chooses a unique username
|
||||
2. **Real-time Validation**: Immediate feedback on username availability
|
||||
3. **Passkey Creation**: Browser prompts for passkey registration
|
||||
4. **Account Creation**: Automatic account creation with passkey
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. **Passkey Detection**: System shows available passkeys
|
||||
2. **Biometric Prompt**: Browser authenticates with passkey
|
||||
3. **Session Creation**: Secure session established
|
||||
4. **Redirect**: User directed to dashboard
|
||||
|
||||
### Security Settings
|
||||
|
||||
Users can manage their security through the Settings > Security page:
|
||||
|
||||
- **Device Management**: View, add, remove, and label passkeys
|
||||
- **Recovery Codes**: Generate new recovery codes
|
||||
- **Password Options**: Add or remove password fallback
|
||||
- **Account Recovery**: Secure account recovery options
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **WebAuthn Not Supported**: Use a modern browser (Chrome, Firefox, Safari, Edge)
|
||||
2. **HTTPS Required**: WebAuthn requires HTTPS in production environments
|
||||
3. **Device Compatibility**: Ensure devices support FIDO2/WebAuthn
|
||||
4. **Database Connection**: Verify database connection and migrations
|
||||
|
||||
### Bootstrap Issues
|
||||
|
||||
1. **Permission Denied**: Ensure proper file permissions on bootstrap binary
|
||||
2. **Database Connection**: Check DATABASE_URL configuration
|
||||
3. **Port Conflicts**: Ensure database port is accessible
|
||||
|
||||
### Recovery Process
|
||||
|
||||
If a user loses access to all passkeys:
|
||||
|
||||
1. **Use Recovery Code**: Enter one of the 10 recovery codes
|
||||
2. **Contact Admin**: Admins can assist with account recovery
|
||||
3. **Re-register Passkey**: Set up new passkeys after recovery
|
||||
4. **Generate New Recovery Codes**: Replace used recovery codes
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Key Metrics
|
||||
|
||||
Monitor these metrics for system health:
|
||||
|
||||
- Authentication success/failure rates
|
||||
- Passkey registration and usage patterns
|
||||
- Recovery code usage frequency
|
||||
- Admin action audit logs
|
||||
- Session expiration and renewal rates
|
||||
|
||||
### Maintenance Tasks
|
||||
|
||||
Regular maintenance includes:
|
||||
|
||||
- Clean up expired sessions and recovery codes
|
||||
- Review audit logs for suspicious activity
|
||||
- Update role assignments as needed
|
||||
- Monitor WebAuthn compatibility with browser updates
|
||||
|
||||
## Support and Documentation
|
||||
|
||||
For additional support:
|
||||
|
||||
- **Technical Issues**: Check application logs and database status
|
||||
- **Security Concerns**: Review audit logs and user activity
|
||||
- **Feature Requests**: Follow the contribution guidelines
|
||||
- **Documentation Updates**: Keep this document current with system changes
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2026
|
||||
**Version**: 1.0
|
||||
**Compatibility**: WebAuthn Level 2, FIDO2
|
||||
579
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,579 @@
|
||||
# b0esche.cloud Deployment Guide
|
||||
|
||||
This guide covers production deployment, server configuration, and operations.
|
||||
|
||||
## Production Architecture
|
||||
|
||||
### Server Overview
|
||||
|
||||
| Component | Domain | Port | Container |
|
||||
|-----------|--------|------|-----------|
|
||||
| Flutter Web | www.b0esche.cloud | 80 | `flutter-web` |
|
||||
| Go Backend | go.b0esche.cloud | 8080 | `go-backend` |
|
||||
| PostgreSQL | internal | 5432 | `go-postgres` |
|
||||
| Nextcloud | storage.b0esche.cloud | 80 | `nextcloud` |
|
||||
| Collabora | of.b0esche.cloud | 9980 | `collabora` |
|
||||
| Traefik | - | 80, 443 | `traefik` |
|
||||
|
||||
### Server Directory Structure
|
||||
|
||||
```
|
||||
/opt/
|
||||
├── traefik/
|
||||
│ ├── docker-compose.yml # Traefik + Nextcloud + Collabora
|
||||
│ ├── traefik.yml # Static configuration
|
||||
│ ├── .env # DNS credentials
|
||||
│ └── acme/ # SSL certificates
|
||||
├── go/
|
||||
│ ├── docker-compose.yml # Go backend + PostgreSQL
|
||||
│ ├── .env.production # Production environment
|
||||
│ └── data/
|
||||
│ └── postgres/
|
||||
│ └── backend/
|
||||
│ └── go_cloud/ # Backend source code
|
||||
├── flutter/
|
||||
│ ├── docker-compose.yml # Nginx for Flutter
|
||||
│ ├── nginx.conf # Nginx configuration
|
||||
│ └── web/ # Built Flutter files
|
||||
├── scripts/
|
||||
│ ├── auto-deploy.sh # Daily auto-deployment
|
||||
│ ├── deploy-now.sh # Manual deployment trigger
|
||||
│ ├── backup.sh # Backup script
|
||||
│ ├── monitor.sh # Health monitoring
|
||||
│ └── webhook-server.py # GitLab webhook receiver
|
||||
└── auto-deploy/
|
||||
└── b0esche_cloud_rollout/ # Deployment workspace
|
||||
```
|
||||
|
||||
## Deployment Methods
|
||||
|
||||
### 1. Automatic Deployment (Recommended)
|
||||
|
||||
Deployments run automatically at 3 AM daily via cron:
|
||||
|
||||
```cron
|
||||
0 3 * * * /opt/scripts/auto-deploy.sh >> /var/log/auto-deploy.log 2>&1
|
||||
```
|
||||
|
||||
The auto-deploy script:
|
||||
1. Pulls latest changes from GitLab
|
||||
2. Builds Flutter web app
|
||||
3. Rebuilds Go backend Docker image
|
||||
4. Restarts services
|
||||
5. Validates health checks
|
||||
|
||||
### 2. Manual Deployment (Immediate)
|
||||
|
||||
Trigger an immediate deployment:
|
||||
|
||||
```bash
|
||||
# From local machine
|
||||
ssh b0esche-cloud '/opt/scripts/deploy-now.sh'
|
||||
|
||||
# Or directly on server
|
||||
/opt/scripts/deploy-now.sh
|
||||
```
|
||||
|
||||
### 3. GitLab Webhook (On Push)
|
||||
|
||||
The webhook server listens for push events:
|
||||
|
||||
```bash
|
||||
# Start webhook server (runs as systemd service)
|
||||
systemctl start webhook-server
|
||||
|
||||
# Check webhook logs
|
||||
journalctl -u webhook-server -f
|
||||
```
|
||||
|
||||
## Service Management
|
||||
|
||||
### Starting All Services
|
||||
|
||||
```bash
|
||||
# Start in order (dependencies first)
|
||||
cd /opt/traefik && docker-compose up -d
|
||||
cd /opt/go && docker-compose up -d
|
||||
cd /opt/flutter && docker-compose up -d
|
||||
```
|
||||
|
||||
### Stopping All Services
|
||||
|
||||
```bash
|
||||
cd /opt/flutter && docker-compose down
|
||||
cd /opt/go && docker-compose down
|
||||
cd /opt/traefik && docker-compose down
|
||||
```
|
||||
|
||||
### Restarting Individual Services
|
||||
|
||||
```bash
|
||||
# Restart Go backend
|
||||
cd /opt/go && docker-compose restart go-backend
|
||||
|
||||
# Restart Flutter frontend
|
||||
cd /opt/flutter && docker-compose restart flutter-web
|
||||
|
||||
# Restart Traefik (caution: brief SSL interruption)
|
||||
cd /opt/traefik && docker-compose restart traefik
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# Follow Go backend logs
|
||||
docker logs -f go-backend
|
||||
|
||||
# Follow Flutter/Nginx logs
|
||||
docker logs -f flutter-web
|
||||
|
||||
# Follow Traefik logs
|
||||
docker logs -f traefik
|
||||
|
||||
# All logs with timestamps
|
||||
docker logs -f --timestamps go-backend
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Traefik Configuration
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v2.10
|
||||
container_name: traefik
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./acme:/etc/traefik/acme
|
||||
networks:
|
||||
- proxy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
**traefik.yml:**
|
||||
```yaml
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
websecure:
|
||||
address: ":443"
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
email: admin@b0esche.cloud
|
||||
storage: /etc/traefik/acme/acme.json
|
||||
dnsChallenge:
|
||||
provider: bunny
|
||||
delayBeforeCheck: 30
|
||||
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
```
|
||||
|
||||
### Go Backend Configuration
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: go-postgres
|
||||
environment:
|
||||
POSTGRES_USER: go_backend
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: go_backend
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
go-backend:
|
||||
build:
|
||||
context: ./data/postgres/backend/go_cloud
|
||||
dockerfile: Dockerfile
|
||||
container_name: go-backend
|
||||
env_file: .env.production
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.go.rule=Host(`go.b0esche.cloud`)"
|
||||
- "traefik.http.routers.go.tls.certresolver=letsencrypt"
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- proxy
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
backend:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### Flutter/Nginx Configuration
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
flutter-web:
|
||||
image: nginx:alpine
|
||||
container_name: flutter-web
|
||||
volumes:
|
||||
- ./web:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.flutter.rule=Host(`www.b0esche.cloud`)"
|
||||
- "traefik.http.routers.flutter.tls.certresolver=letsencrypt"
|
||||
networks:
|
||||
- proxy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
**nginx.conf:**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.b0esche.cloud;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Flutter web app routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
}
|
||||
```
|
||||
|
||||
## Database Operations
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker exec -it go-backend sh
|
||||
|
||||
# Run migrations
|
||||
./api migrate up
|
||||
|
||||
# Or from host
|
||||
docker exec go-backend ./api migrate up
|
||||
```
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# Manual backup
|
||||
docker exec go-postgres pg_dump -U go_backend -Fc go_backend > backup.sqlc
|
||||
|
||||
# Restore from backup
|
||||
docker exec -i go-postgres pg_restore -U go_backend -d go_backend < backup.sqlc
|
||||
```
|
||||
|
||||
### Connecting to Database
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec -it go-postgres psql -U go_backend -d go_backend
|
||||
|
||||
# Common queries
|
||||
\dt # List tables
|
||||
\d users # Describe table
|
||||
SELECT count(*) FROM users; # Count users
|
||||
```
|
||||
|
||||
## SSL Certificate Management
|
||||
|
||||
### Certificate Status
|
||||
|
||||
```bash
|
||||
# Check certificate expiry
|
||||
docker exec traefik cat /etc/traefik/acme/acme.json | jq '.letsencrypt.Certificates[].certificate.NotAfter'
|
||||
|
||||
# Force certificate renewal
|
||||
docker restart traefik
|
||||
```
|
||||
|
||||
### Manual Certificate Operations
|
||||
|
||||
```bash
|
||||
# Backup certificates
|
||||
cp -r /opt/traefik/acme /opt/traefik/acme.backup
|
||||
|
||||
# View certificate details
|
||||
openssl s_client -connect www.b0esche.cloud:443 -servername www.b0esche.cloud </dev/null 2>/dev/null | openssl x509 -noout -dates
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check all services
|
||||
/opt/scripts/monitor.sh
|
||||
|
||||
# Manual health checks
|
||||
curl -s https://go.b0esche.cloud/health
|
||||
curl -s -o /dev/null -w "%{http_code}" https://www.b0esche.cloud
|
||||
curl -s -o /dev/null -w "%{http_code}" https://storage.b0esche.cloud
|
||||
```
|
||||
|
||||
### Container Status
|
||||
|
||||
```bash
|
||||
# All containers
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
# Resource usage
|
||||
docker stats --no-stream
|
||||
|
||||
# Container health
|
||||
docker inspect --format='{{.State.Health.Status}}' go-backend
|
||||
```
|
||||
|
||||
### Disk Usage
|
||||
|
||||
```bash
|
||||
# Docker disk usage
|
||||
docker system df
|
||||
|
||||
# PostgreSQL data size
|
||||
du -sh /opt/go/data/postgres
|
||||
|
||||
# Log sizes
|
||||
du -sh /var/lib/docker/containers/*/
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Automated Backups
|
||||
|
||||
Backups run daily via cron:
|
||||
```cron
|
||||
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
|
||||
```
|
||||
|
||||
### Backup Contents
|
||||
|
||||
1. **PostgreSQL database** (pg_dump)
|
||||
2. **Nextcloud database** (mysqldump)
|
||||
3. **Traefik certificates** (/opt/traefik/acme)
|
||||
4. **Configuration files** (.env, docker-compose.yml)
|
||||
5. **Nextcloud data volume**
|
||||
|
||||
### Backup Retention
|
||||
|
||||
- Keep backups for 30 days
|
||||
- Stored in `/opt/backups/b0esche_cloud/`
|
||||
- Compressed as `.tar.gz`
|
||||
|
||||
### Manual Backup
|
||||
|
||||
```bash
|
||||
# Run backup now
|
||||
/opt/scripts/backup.sh
|
||||
|
||||
# List backups
|
||||
ls -lh /opt/backups/b0esche_cloud/
|
||||
```
|
||||
|
||||
### Restore Procedure
|
||||
|
||||
```bash
|
||||
# 1. Stop services
|
||||
cd /opt/go && docker-compose down
|
||||
cd /opt/flutter && docker-compose down
|
||||
|
||||
# 2. Extract backup
|
||||
cd /opt/backups/b0esche_cloud
|
||||
tar -xzf 20260113_020000.tar.gz
|
||||
|
||||
# 3. Restore database
|
||||
docker exec -i go-postgres pg_restore -U go_backend -d go_backend < go_backend.sqlc
|
||||
|
||||
# 4. Restore configurations
|
||||
cp .env.production /opt/go/
|
||||
cp go-docker-compose.yml /opt/go/docker-compose.yml
|
||||
|
||||
# 5. Restart services
|
||||
cd /opt/go && docker-compose up -d
|
||||
cd /opt/flutter && docker-compose up -d
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Service won't start
|
||||
|
||||
```bash
|
||||
# Check logs for errors
|
||||
docker logs go-backend --tail 50
|
||||
|
||||
# Check container status
|
||||
docker inspect go-backend | jq '.[0].State'
|
||||
|
||||
# Check port conflicts
|
||||
netstat -tlnp | grep -E '80|443|8080'
|
||||
```
|
||||
|
||||
#### Database connection issues
|
||||
|
||||
```bash
|
||||
# Test database connectivity
|
||||
docker exec go-backend ping -c 3 postgres
|
||||
|
||||
# Check PostgreSQL logs
|
||||
docker logs go-postgres --tail 50
|
||||
|
||||
# Verify credentials
|
||||
docker exec go-postgres psql -U go_backend -c "SELECT 1"
|
||||
```
|
||||
|
||||
#### SSL certificate errors
|
||||
|
||||
```bash
|
||||
# Check certificate status
|
||||
curl -vI https://www.b0esche.cloud 2>&1 | grep -A 5 "Server certificate"
|
||||
|
||||
# Force renewal
|
||||
docker restart traefik
|
||||
sleep 60
|
||||
curl -vI https://www.b0esche.cloud
|
||||
```
|
||||
|
||||
#### Out of disk space
|
||||
|
||||
```bash
|
||||
# Check disk usage
|
||||
df -h
|
||||
|
||||
# Clean Docker resources
|
||||
docker system prune -a --volumes
|
||||
|
||||
# Clean old backups
|
||||
find /opt/backups -mtime +30 -delete
|
||||
|
||||
# Clean old logs
|
||||
truncate -s 0 /var/log/auto-deploy.log
|
||||
```
|
||||
|
||||
### Emergency Procedures
|
||||
|
||||
#### Rollback Deployment
|
||||
|
||||
```bash
|
||||
# 1. Stop current services
|
||||
cd /opt/go && docker-compose down
|
||||
cd /opt/flutter && docker-compose down
|
||||
|
||||
# 2. Checkout previous version
|
||||
cd /opt/auto-deploy/b0esche_cloud_rollout
|
||||
git log --oneline -10 # Find last working commit
|
||||
git checkout <commit-hash>
|
||||
|
||||
# 3. Redeploy
|
||||
/opt/scripts/auto-deploy.sh
|
||||
```
|
||||
|
||||
#### Database Recovery
|
||||
|
||||
```bash
|
||||
# Find latest backup
|
||||
ls -lt /opt/backups/b0esche_cloud/ | head -5
|
||||
|
||||
# Restore (see Restore Procedure above)
|
||||
```
|
||||
|
||||
#### Full System Recovery
|
||||
|
||||
1. Provision new server
|
||||
2. Install Docker
|
||||
3. Copy `/opt` from backup
|
||||
4. Start services in order
|
||||
5. Restore database from backup
|
||||
6. Verify health checks
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] All services behind Traefik (no direct port exposure)
|
||||
- [ ] SSL certificates valid and auto-renewing
|
||||
- [ ] Database not accessible from internet
|
||||
- [ ] Strong passwords in `.env.production`
|
||||
- [ ] Regular backups verified
|
||||
- [ ] Firewall configured (only 80, 443, 22 open)
|
||||
- [ ] SSH key authentication only
|
||||
- [ ] Auto-deploy logs monitored
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```sql
|
||||
-- Check slow queries
|
||||
SELECT * FROM pg_stat_activity WHERE state = 'active';
|
||||
|
||||
-- Analyze tables
|
||||
ANALYZE;
|
||||
|
||||
-- Vacuum
|
||||
VACUUM ANALYZE;
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Add to nginx.conf for better performance
|
||||
worker_connections 1024;
|
||||
keepalive_timeout 65;
|
||||
gzip_comp_level 6;
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Limit container resources
|
||||
docker update --memory="512m" --cpus="1" go-backend
|
||||
|
||||
# Clean up unused resources
|
||||
docker system prune -f
|
||||
```
|
||||
671
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,671 @@
|
||||
# b0esche.cloud Development Guide
|
||||
|
||||
This guide covers local development setup, coding conventions, and contribution guidelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
| Software | Version | Installation |
|
||||
|----------|---------|--------------|
|
||||
| Go | 1.21+ | `brew install go` |
|
||||
| Flutter | 3.10+ | [flutter.dev](https://flutter.dev/docs/get-started/install) |
|
||||
| Docker | 24+ | [docker.com](https://docker.com) |
|
||||
| PostgreSQL | 15+ | `brew install postgresql@15` or Docker |
|
||||
| Git | 2.x | `brew install git` |
|
||||
|
||||
### Recommended Tools
|
||||
|
||||
- **VS Code** with extensions:
|
||||
- Go
|
||||
- Flutter
|
||||
- Dart
|
||||
- Docker
|
||||
- GitLens
|
||||
- **TablePlus** or **DBeaver** for database management
|
||||
- **Postman** or **Bruno** for API testing
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### Code Security
|
||||
|
||||
- **Never log secrets**: Passwords, tokens, keys must never appear in logs
|
||||
- **Validate all inputs**: Use `sanitizePath()` for file paths, validate UUIDs
|
||||
- **Use structured errors**: Return safe error messages that don't leak internal details
|
||||
- **HTTPS only**: All API calls must use HTTPS in production
|
||||
- **Input sanitization**: All user inputs must be validated and sanitized
|
||||
|
||||
### Authentication
|
||||
|
||||
- **JWT tokens**: Use secure, short-lived tokens
|
||||
- **Session validation**: Always validate sessions against database
|
||||
- **Passkey security**: Follow WebAuthn best practices
|
||||
|
||||
### File Operations
|
||||
|
||||
- **Path validation**: Prevent directory traversal with proper path sanitization
|
||||
- **Permission checks**: Verify user permissions before file operations
|
||||
- **Scoped access**: Users can only access authorized files/orgs
|
||||
|
||||
### Development Security
|
||||
|
||||
- **Local secrets**: Use `.env` files, never commit secrets
|
||||
- **Test with security**: Include security tests in development
|
||||
- **Review code**: Security review for all changes
|
||||
|
||||
## Project Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://lab.b0esche.cloud/b0esche/b0esche_cloud.git
|
||||
cd b0esche_cloud
|
||||
```
|
||||
|
||||
### 2. Backend Setup
|
||||
|
||||
```bash
|
||||
cd go_cloud
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your local settings
|
||||
# Key variables to set:
|
||||
# - DATABASE_URL=postgres://user:pass@localhost:5432/b0esche_dev?sslmode=disable
|
||||
# - JWT_SECRET=your-dev-secret
|
||||
# - WEBAUTHN_RP_ID=localhost
|
||||
# - WEBAUTHN_RP_ORIGIN=http://localhost:8080
|
||||
```
|
||||
|
||||
#### Start PostgreSQL
|
||||
|
||||
**Option A: Using Docker (Recommended)**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name b0esche-postgres \
|
||||
-e POSTGRES_USER=b0esche \
|
||||
-e POSTGRES_PASSWORD=devpassword \
|
||||
-e POSTGRES_DB=b0esche_dev \
|
||||
-p 5432:5432 \
|
||||
postgres:15-alpine
|
||||
```
|
||||
|
||||
**Option B: Using local PostgreSQL**
|
||||
```bash
|
||||
createdb b0esche_dev
|
||||
```
|
||||
|
||||
#### Run Migrations
|
||||
|
||||
```bash
|
||||
# Install goose
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
|
||||
# Run migrations
|
||||
goose -dir migrations postgres "$DATABASE_URL" up
|
||||
```
|
||||
|
||||
#### Start Backend
|
||||
|
||||
```bash
|
||||
# Development mode with hot reload
|
||||
go run ./cmd/api
|
||||
|
||||
# Or build and run
|
||||
go build -o bin/api ./cmd/api
|
||||
./bin/api
|
||||
```
|
||||
|
||||
The backend will be available at `http://localhost:8080`.
|
||||
|
||||
### 3. Frontend Setup
|
||||
|
||||
```bash
|
||||
cd b0esche_cloud
|
||||
|
||||
# Get dependencies
|
||||
flutter pub get
|
||||
|
||||
# Run in Chrome (recommended for web development)
|
||||
flutter run -d chrome
|
||||
|
||||
# Or run with specific port
|
||||
flutter run -d chrome --web-port=3000
|
||||
```
|
||||
|
||||
The frontend will be available at `http://localhost:3000` (or the port shown).
|
||||
|
||||
### 4. Quick Start Script
|
||||
|
||||
Use the provided development script:
|
||||
|
||||
```bash
|
||||
./scripts/dev-all.sh
|
||||
```
|
||||
|
||||
This starts all services in the correct order.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Backend (`go_cloud/`)
|
||||
|
||||
```
|
||||
go_cloud/
|
||||
├── cmd/
|
||||
│ └── api/
|
||||
│ └── main.go # Application entry point
|
||||
├── internal/
|
||||
│ ├── auth/
|
||||
│ │ ├── auth.go # Authentication service
|
||||
│ │ ├── passkey.go # WebAuthn implementation
|
||||
│ │ └── auth_test.go # Tests
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Configuration loading
|
||||
│ ├── database/
|
||||
│ │ └── database.go # Database connection
|
||||
│ ├── files/
|
||||
│ │ └── files.go # File operations
|
||||
│ ├── http/
|
||||
│ │ ├── routes.go # Route definitions
|
||||
│ │ ├── server.go # HTTP server setup
|
||||
│ │ └── wopi_handlers.go # WOPI protocol handlers
|
||||
│ ├── middleware/
|
||||
│ │ └── middleware.go # HTTP middleware
|
||||
│ ├── models/
|
||||
│ │ └── *.go # Data models
|
||||
│ ├── org/
|
||||
│ │ └── org.go # Organization logic
|
||||
│ ├── storage/
|
||||
│ │ ├── nextcloud.go # Nextcloud integration
|
||||
│ │ └── webdav.go # WebDAV client
|
||||
│ └── ...
|
||||
├── migrations/
|
||||
│ ├── 0001_initial.sql
|
||||
│ ├── 0002_passkeys.sql
|
||||
│ └── ...
|
||||
├── pkg/
|
||||
│ └── jwt/
|
||||
│ └── jwt.go # JWT utilities
|
||||
├── .env.example
|
||||
├── Dockerfile
|
||||
├── go.mod
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
### Frontend (`b0esche_cloud/`)
|
||||
|
||||
```
|
||||
b0esche_cloud/
|
||||
├── lib/
|
||||
│ ├── main.dart # App entry point
|
||||
│ ├── injection.dart # Dependency injection
|
||||
│ ├── blocs/
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── auth_bloc.dart
|
||||
│ │ │ ├── auth_event.dart
|
||||
│ │ │ └── auth_state.dart
|
||||
│ │ ├── files/
|
||||
│ │ └── org/
|
||||
│ ├── models/
|
||||
│ │ ├── user.dart
|
||||
│ │ ├── file.dart
|
||||
│ │ └── organization.dart
|
||||
│ ├── pages/
|
||||
│ │ ├── home_page.dart
|
||||
│ │ ├── files_page.dart
|
||||
│ │ ├── settings_page.dart
|
||||
│ │ └── admin/
|
||||
│ ├── repositories/
|
||||
│ │ ├── auth_repository.dart
|
||||
│ │ └── file_repository.dart
|
||||
│ ├── services/
|
||||
│ │ ├── api_client.dart
|
||||
│ │ └── webauthn_service.dart
|
||||
│ ├── theme/
|
||||
│ │ └── app_theme.dart
|
||||
│ └── widgets/
|
||||
│ ├── file_list.dart
|
||||
│ └── ...
|
||||
├── web/
|
||||
│ └── index.html
|
||||
├── pubspec.yaml
|
||||
└── analysis_options.yaml
|
||||
```
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### Go Backend
|
||||
|
||||
#### Code Style
|
||||
- Follow [Effective Go](https://golang.org/doc/effective_go)
|
||||
- Use `gofmt` for formatting
|
||||
- Use `golint` and `go vet` for linting
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
gofmt -w .
|
||||
|
||||
# Lint
|
||||
golint ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
#### Naming Conventions
|
||||
- **Packages**: lowercase, single word (`auth`, `files`)
|
||||
- **Exported functions**: PascalCase (`CreateUser`)
|
||||
- **Private functions**: camelCase (`validateToken`)
|
||||
- **Constants**: PascalCase (`DefaultTimeout`)
|
||||
|
||||
#### Error Handling
|
||||
```go
|
||||
// Always handle errors explicitly
|
||||
user, err := s.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Use custom error types for API errors
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
```
|
||||
|
||||
#### Project Patterns
|
||||
```go
|
||||
// Service pattern
|
||||
type AuthService struct {
|
||||
db *sqlx.DB
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewAuthService(db *sqlx.DB, config *config.Config) *AuthService {
|
||||
return &AuthService{db: db, config: config}
|
||||
}
|
||||
|
||||
// Handler pattern
|
||||
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse request
|
||||
// Call service
|
||||
// Return response
|
||||
}
|
||||
```
|
||||
|
||||
### Flutter Frontend
|
||||
|
||||
#### Code Style
|
||||
- Follow [Effective Dart](https://dart.dev/guides/language/effective-dart)
|
||||
- Use `dart format` for formatting
|
||||
- Use `dart analyze` for linting
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
dart format .
|
||||
|
||||
# Analyze
|
||||
dart analyze
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
#### Naming Conventions
|
||||
- **Classes**: PascalCase (`AuthBloc`)
|
||||
- **Files**: snake_case (`auth_bloc.dart`)
|
||||
- **Variables/Functions**: camelCase (`getUserName`)
|
||||
- **Constants**: camelCase or SCREAMING_CAPS
|
||||
|
||||
#### BLoC Pattern
|
||||
```dart
|
||||
// Events
|
||||
abstract class AuthEvent {}
|
||||
class LoginRequested extends AuthEvent {
|
||||
final String username;
|
||||
LoginRequested(this.username);
|
||||
}
|
||||
|
||||
// States
|
||||
abstract class AuthState {}
|
||||
class AuthInitial extends AuthState {}
|
||||
class AuthLoading extends AuthState {}
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
AuthAuthenticated(this.user);
|
||||
}
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
AuthError(this.message);
|
||||
}
|
||||
|
||||
// BLoC
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc() : super(AuthInitial()) {
|
||||
on<LoginRequested>(_onLoginRequested);
|
||||
}
|
||||
|
||||
Future<void> _onLoginRequested(
|
||||
LoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final user = await _authRepository.login(event.username);
|
||||
emit(AuthAuthenticated(user));
|
||||
} catch (e) {
|
||||
emit(AuthError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Widget Structure
|
||||
```dart
|
||||
class MyWidget extends StatelessWidget {
|
||||
const MyWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MyBloc, MyState>(
|
||||
builder: (context, state) {
|
||||
return switch (state) {
|
||||
MyLoading() => const CircularProgressIndicator(),
|
||||
MyLoaded(:final data) => _buildContent(data),
|
||||
MyError(:final message) => Text('Error: $message'),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
cd go_cloud
|
||||
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/auth/...
|
||||
|
||||
# Verbose output
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
Example test:
|
||||
```go
|
||||
func TestAuthService_Login(t *testing.T) {
|
||||
// Setup
|
||||
db := setupTestDB(t)
|
||||
service := NewAuthService(db, testConfig)
|
||||
|
||||
// Test
|
||||
user, err := service.Login(context.Background(), "testuser")
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "testuser", user.Username)
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
cd b0esche_cloud
|
||||
|
||||
# Run all tests
|
||||
flutter test
|
||||
|
||||
# Run with coverage
|
||||
flutter test --coverage
|
||||
|
||||
# Run specific test file
|
||||
flutter test test/auth_bloc_test.dart
|
||||
|
||||
# Run integration tests
|
||||
flutter test integration_test/
|
||||
```
|
||||
|
||||
Example test:
|
||||
```dart
|
||||
void main() {
|
||||
group('AuthBloc', () {
|
||||
late AuthBloc authBloc;
|
||||
late MockAuthRepository mockRepository;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockAuthRepository();
|
||||
authBloc = AuthBloc(authRepository: mockRepository);
|
||||
});
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [AuthLoading, AuthAuthenticated] on successful login',
|
||||
build: () => authBloc,
|
||||
act: (bloc) => bloc.add(LoginRequested('testuser')),
|
||||
expect: () => [
|
||||
AuthLoading(),
|
||||
isA<AuthAuthenticated>(),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### Creating a Migration
|
||||
|
||||
```bash
|
||||
cd go_cloud
|
||||
|
||||
# Create new migration
|
||||
goose -dir migrations create add_new_table sql
|
||||
```
|
||||
|
||||
### Migration Best Practices
|
||||
|
||||
```sql
|
||||
-- migrations/0005_add_feature.sql
|
||||
|
||||
-- +goose Up
|
||||
-- Add new column with default
|
||||
ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT '';
|
||||
|
||||
-- Create index for performance
|
||||
CREATE INDEX idx_users_new_field ON users(new_field);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_users_new_field;
|
||||
ALTER TABLE users DROP COLUMN new_field;
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
# Apply all pending migrations
|
||||
goose -dir migrations postgres "$DATABASE_URL" up
|
||||
|
||||
# Rollback last migration
|
||||
goose -dir migrations postgres "$DATABASE_URL" down
|
||||
|
||||
# Check migration status
|
||||
goose -dir migrations postgres "$DATABASE_URL" status
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Backend Debugging
|
||||
|
||||
**VS Code launch.json:**
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Go Backend",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/go_cloud/cmd/api",
|
||||
"envFile": "${workspaceFolder}/go_cloud/.env"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Logging:**
|
||||
```go
|
||||
import "log"
|
||||
|
||||
log.Printf("User login attempt: %s", username)
|
||||
```
|
||||
|
||||
### Frontend Debugging
|
||||
|
||||
**Chrome DevTools:**
|
||||
- Press F12 in Chrome
|
||||
- Use the Flutter DevTools extension
|
||||
|
||||
**Debug print:**
|
||||
```dart
|
||||
debugPrint('Current state: $state');
|
||||
```
|
||||
|
||||
**VS Code launch.json:**
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flutter Web",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"deviceId": "chrome"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Git Workflow
|
||||
|
||||
### Branch Naming
|
||||
|
||||
- `feature/description` - New features
|
||||
- `fix/description` - Bug fixes
|
||||
- `refactor/description` - Code refactoring
|
||||
- `docs/description` - Documentation updates
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow conventional commits:
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
feat(auth): add passkey registration flow
|
||||
fix(files): correct upload progress display
|
||||
docs(readme): update deployment instructions
|
||||
refactor(api): extract common error handling
|
||||
```
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Create feature branch from `main`
|
||||
2. Make changes with atomic commits
|
||||
3. Run tests locally
|
||||
4. Push and create PR
|
||||
5. Wait for review
|
||||
6. Squash and merge
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Backend won't start
|
||||
```bash
|
||||
# Check if port is in use
|
||||
lsof -i :8080
|
||||
|
||||
# Check database connection
|
||||
psql $DATABASE_URL -c "SELECT 1"
|
||||
|
||||
# Check logs
|
||||
go run ./cmd/api 2>&1 | head -50
|
||||
```
|
||||
|
||||
#### Flutter build fails
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run -d chrome
|
||||
|
||||
# Check for dependency issues
|
||||
flutter pub deps
|
||||
```
|
||||
|
||||
#### Database migration fails
|
||||
```bash
|
||||
# Check current status
|
||||
goose -dir migrations postgres "$DATABASE_URL" status
|
||||
|
||||
# Force specific version
|
||||
goose -dir migrations postgres "$DATABASE_URL" fix
|
||||
```
|
||||
|
||||
#### WebAuthn not working locally
|
||||
- WebAuthn requires HTTPS in production
|
||||
- For localhost, use `WEBAUTHN_RP_ID=localhost`
|
||||
- Chrome allows WebAuthn on localhost without HTTPS
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```bash
|
||||
# Server
|
||||
SERVER_ADDR=:8080
|
||||
DEV_MODE=true
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/dbname?sslmode=disable
|
||||
|
||||
# Authentication
|
||||
JWT_SECRET=your-secret-key
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_RP_NAME=b0esche.cloud
|
||||
WEBAUTHN_RP_ORIGIN=http://localhost:8080
|
||||
|
||||
# External Services (optional for local dev)
|
||||
NEXTCLOUD_BASE_URL=https://storage.b0esche.cloud
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
COLLABORA_BASE_URL=https://of.b0esche.cloud
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
API base URL is configured in `lib/services/api_client.dart`:
|
||||
```dart
|
||||
class ApiClient {
|
||||
// For development
|
||||
static const baseUrl = 'http://localhost:8080';
|
||||
|
||||
// For production (set via build args)
|
||||
// static const baseUrl = String.fromEnvironment('API_URL');
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
- [Flutter Documentation](https://flutter.dev/docs)
|
||||
- [WebAuthn Guide](https://webauthn.guide/)
|
||||
- [BLoC Library](https://bloclibrary.dev/)
|
||||
- [Chi Router](https://github.com/go-chi/chi)
|
||||
196
docs/SECURITY.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# b0esche.cloud Security Guide
|
||||
|
||||
This document describes the security architecture, configurations, and best practices for b0esche.cloud.
|
||||
|
||||
## Security Architecture Overview
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Traefik │ ← Only public entrypoint
|
||||
│ (443, 80) │ TLS termination
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Flutter │ │ Go API │ │Collabora │
|
||||
│ Web │ │ Backend │ │ Online │
|
||||
└──────────┘ └────┬─────┘ └──────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│PostgreSQL│ │Nextcloud │ │ Redis │
|
||||
│(internal)│ │(storage) │ │(sessions)│
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Authentication Security
|
||||
|
||||
### Primary: Passkeys (WebAuthn)
|
||||
|
||||
- **Protocol**: WebAuthn/FIDO2 standard
|
||||
- **Cryptography**: ECDSA with P-256 or RSA with 2048+ bits
|
||||
- **Origin Binding**: Strictly bound to `https://b0esche.cloud`
|
||||
- **RP ID**: `b0esche.cloud`
|
||||
- **Challenge Generation**: 32 bytes of cryptographically secure random data
|
||||
- **Challenge Expiry**: 60 seconds
|
||||
|
||||
### Fallback: Password Authentication
|
||||
|
||||
- **Hashing**: Argon2id (OWASP recommended parameters)
|
||||
- Time: 2 iterations
|
||||
- Memory: 19 MB
|
||||
- Threads: 1
|
||||
- Key Length: 32 bytes
|
||||
- **Password Requirements**: Minimum 8 characters (enforced server-side)
|
||||
|
||||
### Session Management
|
||||
|
||||
- **Token Format**: JWT (HS256)
|
||||
- **Token Lifetime**: 15 minutes (auto-refresh enabled)
|
||||
- **Session Storage**: Database-backed with revocation support
|
||||
- **Session Validation**: Every request validates session is not revoked
|
||||
|
||||
## Authorization
|
||||
|
||||
### Role-Based Access Control (RBAC)
|
||||
|
||||
| Role | Level | Permissions |
|
||||
|------|-------|-------------|
|
||||
| superadmin | 3 | Full system access, user management |
|
||||
| admin | 2 | Organization management, user roles |
|
||||
| user | 1 | Personal files, org membership |
|
||||
|
||||
### Organization Scoping
|
||||
|
||||
- All file operations are scoped to authenticated user + organization
|
||||
- Membership verification on every org-scoped request
|
||||
- Permission checks via middleware pipeline
|
||||
|
||||
## API Security
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **General Endpoints**: 100 requests/minute per IP
|
||||
- **Auth Endpoints**: 10 requests/minute per IP (brute-force protection)
|
||||
- **Implementation**: Sliding window algorithm
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Path Traversal Prevention**: All file paths are sanitized
|
||||
- `..` sequences are rejected
|
||||
- Paths are cleaned and normalized
|
||||
- **UUID Validation**: All IDs are validated as proper UUIDs
|
||||
- **File Size Limits**: 32MB maximum upload size
|
||||
|
||||
### Output Security
|
||||
|
||||
- **No Stack Traces**: Error responses never include stack traces
|
||||
- **Structured Errors**: Consistent error format with codes:
|
||||
- `UNAUTHENTICATED` (401)
|
||||
- `PERMISSION_DENIED` (403)
|
||||
- `NOT_FOUND` (404)
|
||||
- `INVALID_ARGUMENT` (400)
|
||||
- `INTERNAL` (500)
|
||||
- **No Secrets in Logs**: Passwords and tokens are never logged
|
||||
|
||||
### Security Headers
|
||||
|
||||
The application sets comprehensive security headers:
|
||||
|
||||
- **X-Content-Type-Options**: `nosniff` - Prevents MIME type sniffing
|
||||
- **X-Frame-Options**: `DENY` - Prevents clickjacking (except for WOPI endpoints)
|
||||
- **X-XSS-Protection**: `1; mode=block` - Enables XSS filtering
|
||||
- **Content-Security-Policy**: Restrictive policy allowing only necessary sources
|
||||
- **Referrer-Policy**: `strict-origin-when-cross-origin` - Controls referrer information
|
||||
- **CORS**: Restricted to allowed origins with credentials support
|
||||
|
||||
## Network Security
|
||||
|
||||
### TLS Configuration
|
||||
|
||||
- **Protocol**: TLS 1.2 minimum (TLS 1.3 preferred)
|
||||
- **Certificate**: Let's Encrypt (auto-renewed via DNS-01 challenge)
|
||||
- **HSTS**: Enabled with 1-year max-age
|
||||
|
||||
### CORS Policy
|
||||
|
||||
- **Allowed Origins**:
|
||||
- `https://b0esche.cloud`
|
||||
- `https://www.b0esche.cloud`
|
||||
- `https://*.b0esche.cloud`
|
||||
- **Credentials**: Allowed
|
||||
- **Methods**: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
||||
- **Max Age**: 3600 seconds
|
||||
|
||||
### Port Exposure
|
||||
|
||||
| Port | Service | Exposed To |
|
||||
|------|---------|------------|
|
||||
| 443 | Traefik (HTTPS) | Internet |
|
||||
| 80 | Traefik (HTTP→HTTPS) | Internet |
|
||||
| 22 | SSH | Internet (key-only) |
|
||||
| 8080 | Go Backend | Internal only |
|
||||
| 5432 | PostgreSQL | Internal only |
|
||||
| 9980 | Collabora | Internal only |
|
||||
|
||||
## Secure Development Practices
|
||||
|
||||
### Code Security Checklist
|
||||
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] No debug logging of sensitive data
|
||||
- [ ] Input validation on all endpoints
|
||||
- [ ] Path sanitization for file operations
|
||||
- [ ] Parameterized SQL queries (no string concatenation)
|
||||
- [ ] Error responses don't leak internal details
|
||||
|
||||
### Deployment Security
|
||||
|
||||
- [ ] Production secrets via environment variables only
|
||||
- [ ] `.env` files excluded from git
|
||||
- [ ] Docker containers run as non-root where possible
|
||||
- [ ] Regular dependency updates
|
||||
|
||||
## Incident Response
|
||||
|
||||
### Logging
|
||||
|
||||
All security-relevant events are logged:
|
||||
- Login attempts (success/failure)
|
||||
- Session creation/revocation
|
||||
- Permission denials
|
||||
- Rate limit violations
|
||||
- File access (view/edit/delete)
|
||||
|
||||
### Log Format
|
||||
|
||||
```
|
||||
[LEVEL] req_id=<uuid> user_id=<uuid> org_id=<uuid> action=<string>: message
|
||||
```
|
||||
|
||||
### Audit Trail
|
||||
|
||||
The `activities` table stores:
|
||||
- User actions (file operations, org changes)
|
||||
- Timestamps
|
||||
- Associated resources
|
||||
- Success/failure status
|
||||
|
||||
## Security Contacts
|
||||
|
||||
For security issues, contact the system administrator directly.
|
||||
Do not report security vulnerabilities in public issue trackers.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-01-13 | Initial security documentation |
|
||||
| 2026-01-13 | Removed debug password logging |
|
||||
| 2026-01-13 | Added rate limiting |
|
||||
| 2026-01-13 | Added path traversal protection |
|
||||
32
go_cloud/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# ---------- Build stage ----------
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certs for HTTPS / OIDC
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build statically linked binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -o backend ./cmd/api
|
||||
|
||||
# ---------- Runtime stage ----------
|
||||
FROM gcr.io/distroless/base-debian12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/backend /app/backend
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER nonroot:nonroot
|
||||
|
||||
ENTRYPOINT ["/app/backend"]
|
||||
BIN
go_cloud/api
BIN
go_cloud/bin/api
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/audit"
|
||||
"go.b0esche.cloud/backend/internal/auth"
|
||||
@@ -13,9 +14,43 @@ import (
|
||||
"go.b0esche.cloud/backend/pkg/jwt"
|
||||
)
|
||||
|
||||
// ensureAvatarCacheDir finds a writable, preferably persistent directory for avatar cache and updates cfg
|
||||
func ensureAvatarCacheDir(cfg *config.Config) {
|
||||
candidates := []string{
|
||||
cfg.AvatarCacheDir,
|
||||
"/var/lib/b0esche/avatars",
|
||||
"./data/avatars",
|
||||
filepath.Join(os.TempDir(), "b0esche_avatars"),
|
||||
}
|
||||
|
||||
for _, d := range candidates {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(d, 0755); err == nil {
|
||||
// Try writing a small test file to confirm write permission
|
||||
testPath := filepath.Join(d, ".write_test")
|
||||
if err := os.WriteFile(testPath, []byte("ok"), 0644); err == nil {
|
||||
os.Remove(testPath)
|
||||
if d != cfg.AvatarCacheDir {
|
||||
fmt.Printf("[WARN] Avatar cache dir %q not usable, using %q instead. Please set AVATAR_CACHE_DIR to a persistent, writable volume.\n", cfg.AvatarCacheDir, d)
|
||||
}
|
||||
cfg.AvatarCacheDir = d
|
||||
fmt.Printf("[INFO] Avatar cache directory set to %q\n", d)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// If none usable, keep configured value and let runtime fallback handle it
|
||||
fmt.Printf("[WARN] No writable persistent avatar cache directory found; falling back to tmp. Set AVATAR_CACHE_DIR to a persistent path.\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
// Ensure avatar cache directory is usable and persistent when possible
|
||||
ensureAvatarCacheDir(cfg)
|
||||
|
||||
dbConn, err := database.Connect(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
module go.b0esche.cloud/backend
|
||||
|
||||
go 1.25.5
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgconn v1.13.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
||||
131
go_cloud/go.sum
@@ -1,35 +1,162 @@
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
|
||||
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
|
||||
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateState(t *testing.T) {
|
||||
state1, err := GenerateState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state2, err := GenerateState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state1 == state2 {
|
||||
t.Error("States should be unique")
|
||||
}
|
||||
if len(state1) == 0 {
|
||||
t.Error("State should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
// Mock db
|
||||
// service, err := NewService(cfg, db)
|
||||
// TODO: Mock database for full test
|
||||
t.Skip("Requires database mock")
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,12 @@ const (
|
||||
RPID = "b0esche.cloud"
|
||||
RPName = "b0esche Cloud"
|
||||
Origin = "https://b0esche.cloud"
|
||||
|
||||
// Argon2id parameters (OWASP recommendations)
|
||||
Argon2Time = 2 // iterations
|
||||
Argon2Memory = 19 * 1024 // 19 MB
|
||||
Argon2Threads = 1
|
||||
Argon2KeyLen = 32
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
// HashPassword hashes a password using Argon2id (quantum-resistant)
|
||||
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
func (s *Service) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
// Generate 16-byte random salt
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
|
||||
// Hash with Argon2id
|
||||
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
|
||||
|
||||
// Encode in PHC string format
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if a password matches its hash
|
||||
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
|
||||
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
// Detect hash format
|
||||
if strings.HasPrefix(passwordHash, "$argon2id$") {
|
||||
return s.verifyArgon2(passwordHash, password)
|
||||
} else if strings.HasPrefix(passwordHash, "$2") {
|
||||
// Legacy bcrypt hash
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
|
||||
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
|
||||
|
||||
// Constant-time comparison
|
||||
if len(hash) != len(computedHash) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(hash); i++ {
|
||||
diff |= hash[i] ^ computedHash[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// VerifyPasswordLogin verifies username and password credentials
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerAddr string
|
||||
DatabaseURL string
|
||||
OIDCIssuerURL string
|
||||
OIDCRedirectURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
JWTSecret string
|
||||
ServerAddr string
|
||||
DatabaseURL string
|
||||
OIDCIssuerURL string
|
||||
OIDCRedirectURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
JWTSecret string
|
||||
NextcloudURL string
|
||||
NextcloudUser string
|
||||
NextcloudPass string
|
||||
NextcloudBase string
|
||||
AllowedOrigins string
|
||||
AvatarCacheDir string
|
||||
AvatarDownloadTimeoutSeconds int
|
||||
AvatarDownloadRetries int
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
||||
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
cfg := &Config{
|
||||
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
||||
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
||||
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
||||
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
||||
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
|
||||
AvatarCacheDir: getEnv("AVATAR_CACHE_DIR", "/var/cache/b0esche/avatars"),
|
||||
AvatarDownloadTimeoutSeconds: getEnvInt("AVATAR_DOWNLOAD_TIMEOUT_SECONDS", 20),
|
||||
AvatarDownloadRetries: getEnvInt("AVATAR_DOWNLOAD_RETRIES", 3),
|
||||
}
|
||||
log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q, AvatarDownloadTimeoutSeconds: %d, AvatarDownloadRetries: %d\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir, cfg.AvatarDownloadTimeoutSeconds, cfg.AvatarDownloadRetries)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
@@ -32,3 +52,12 @@ func getEnv(key, defaultVal string) string {
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) int {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/models"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
@@ -16,14 +20,57 @@ func New(db *sql.DB) *DB {
|
||||
return &DB{DB: db}
|
||||
}
|
||||
|
||||
// StringArray handles nullable string arrays from PostgreSQL
|
||||
type StringArray []string
|
||||
|
||||
// Scan handles NULL values properly
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle byte slice from PostgreSQL array
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
var arr []string
|
||||
if err := json.Unmarshal(bytes, &arr); err != nil {
|
||||
// If JSON parse fails, try as raw string
|
||||
*sa = StringArray{string(bytes)}
|
||||
return nil
|
||||
}
|
||||
*sa = StringArray(arr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle string directly
|
||||
if str, ok := value.(string); ok {
|
||||
if str == "" {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
*sa = StringArray{str}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface
|
||||
func (sa StringArray) Value() (driver.Value, error) {
|
||||
if len(sa) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(sa)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
Username string
|
||||
DisplayName string
|
||||
PasswordHash *string
|
||||
CreatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
PasswordHash *string `json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
@@ -34,7 +81,7 @@ type Credential struct {
|
||||
SignCount int64
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
Transports []string
|
||||
Transports StringArray
|
||||
}
|
||||
|
||||
type AuthChallenge struct {
|
||||
@@ -55,10 +102,12 @@ type Session struct {
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
InviteLinkToken *string `json:"inviteLinkToken,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
@@ -68,6 +117,26 @@ type Membership struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrgID uuid.UUID `json:"orgId"`
|
||||
InvitedBy uuid.UUID `json:"invitedBy"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt"`
|
||||
}
|
||||
|
||||
type JoinRequest struct {
|
||||
ID uuid.UUID
|
||||
OrgID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
InviteToken *string
|
||||
RequestedAt time.Time
|
||||
Status string
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
@@ -78,6 +147,20 @@ type Activity struct {
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type File struct {
|
||||
ID uuid.UUID
|
||||
OrgID *uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
Name string
|
||||
Path string
|
||||
Type string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
CreatedAt time.Time
|
||||
ModifiedBy *uuid.UUID
|
||||
ModifiedByName string
|
||||
}
|
||||
|
||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||
var user User
|
||||
err := db.QueryRowContext(ctx, `
|
||||
@@ -120,9 +203,18 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET revoked_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
`, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT o.id, o.name, o.slug, o.created_at
|
||||
SELECT o.id, o.owner_id, o.name, o.slug, o.created_at
|
||||
FROM organizations o
|
||||
JOIN memberships m ON o.id = m.org_id
|
||||
WHERE m.user_id = $1
|
||||
@@ -135,7 +227,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
|
||||
var orgs []Organization
|
||||
for rows.Next() {
|
||||
var org Organization
|
||||
if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgs = append(orgs, org)
|
||||
@@ -156,13 +248,20 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
|
||||
// GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org
|
||||
func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) {
|
||||
return db.GetUserMembership(ctx, userID, orgID)
|
||||
}
|
||||
|
||||
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) {
|
||||
// Generate a unique invite link token
|
||||
inviteToken := uuid.New().String()
|
||||
var org Organization
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO organizations (name, slug)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, slug, created_at
|
||||
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
|
||||
INSERT INTO organizations (owner_id, name, slug, invite_link_token)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, owner_id, name, slug, invite_link_token, created_at
|
||||
`, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -233,15 +332,685 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
||||
return memberships, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||
// GetOrgMembersWithUsers returns members with user details
|
||||
func (db *DB) GetOrgMembersWithUsers(ctx context.Context, orgID uuid.UUID) ([]struct {
|
||||
Membership
|
||||
User
|
||||
}, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT m.user_id, m.org_id, m.role, m.created_at,
|
||||
u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at
|
||||
FROM memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.org_id = $1
|
||||
ORDER BY m.created_at
|
||||
`, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make([]struct {
|
||||
Membership
|
||||
User
|
||||
}, 0)
|
||||
for rows.Next() {
|
||||
var m struct {
|
||||
Membership
|
||||
User
|
||||
}
|
||||
err := rows.Scan(
|
||||
&m.Membership.UserID, &m.Membership.OrgID, &m.Membership.Role, &m.Membership.CreatedAt,
|
||||
&m.User.ID, &m.User.Email, &m.User.Username, &m.User.DisplayName, &m.User.CreatedAt, &m.User.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateMemberRole updates a member's role
|
||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, newRole string) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE memberships
|
||||
SET role = $1
|
||||
WHERE org_id = $2 AND user_id = $3
|
||||
`, role, orgID, userID)
|
||||
`, newRole, orgID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMember removes a user from an organization
|
||||
func (db *DB) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
DELETE FROM memberships
|
||||
WHERE org_id = $1 AND user_id = $2
|
||||
`, orgID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SearchUsersByUsername searches users by partial username match
|
||||
func (db *DB) SearchUsersByUsername(ctx context.Context, query string, limit int) ([]User, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, email, username, display_name, created_at, last_login_at
|
||||
FROM users
|
||||
WHERE username ILIKE $1
|
||||
ORDER BY username
|
||||
LIMIT $2
|
||||
`, "%"+query+"%", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]User, 0)
|
||||
for rows.Next() {
|
||||
var u User
|
||||
err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.DisplayName, &u.CreatedAt, &u.LastLoginAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// CreateInvitation creates a new invitation
|
||||
func (db *DB) CreateInvitation(ctx context.Context, orgID, invitedBy uuid.UUID, username, role string) (*Invitation, error) {
|
||||
var inv Invitation
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO invitations (org_id, invited_by, username, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, org_id, invited_by, username, role, created_at, expires_at, accepted_at
|
||||
`, orgID, invitedBy, username, role).Scan(
|
||||
&inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role,
|
||||
&inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// GetOrgInvitations returns pending invitations for an org
|
||||
func (db *DB) GetOrgInvitations(ctx context.Context, orgID uuid.UUID) ([]Invitation, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, org_id, invited_by, username, role, created_at, expires_at, accepted_at
|
||||
FROM invitations
|
||||
WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
`, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
invitations := make([]Invitation, 0)
|
||||
for rows.Next() {
|
||||
var inv Invitation
|
||||
err := rows.Scan(
|
||||
&inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role,
|
||||
&inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invitations = append(invitations, inv)
|
||||
}
|
||||
return invitations, rows.Err()
|
||||
}
|
||||
|
||||
// CancelInvitation cancels an invitation
|
||||
func (db *DB) CancelInvitation(ctx context.Context, invitationID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
DELETE FROM invitations
|
||||
WHERE id = $1
|
||||
`, invitationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateJoinRequest creates a join request
|
||||
func (db *DB) CreateJoinRequest(ctx context.Context, orgID, userID uuid.UUID, inviteToken *string) (*JoinRequest, error) {
|
||||
var req JoinRequest
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO join_requests (org_id, user_id, invite_token)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (org_id, user_id) DO UPDATE SET
|
||||
invite_token = EXCLUDED.invite_token,
|
||||
requested_at = NOW(),
|
||||
status = 'pending'
|
||||
RETURNING id, org_id, user_id, invite_token, requested_at, status
|
||||
`, orgID, userID, inviteToken).Scan(
|
||||
&req.ID, &req.OrgID, &req.UserID, &req.InviteToken, &req.RequestedAt, &req.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// GetOrgJoinRequests returns pending join requests for an org
|
||||
func (db *DB) GetOrgJoinRequests(ctx context.Context, orgID uuid.UUID) ([]struct {
|
||||
JoinRequest
|
||||
User
|
||||
}, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT jr.id, jr.org_id, jr.user_id, jr.invite_token, jr.requested_at, jr.status,
|
||||
u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at
|
||||
FROM join_requests jr
|
||||
JOIN users u ON jr.user_id = u.id
|
||||
WHERE jr.org_id = $1 AND jr.status = 'pending'
|
||||
ORDER BY jr.requested_at DESC
|
||||
`, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
requests := make([]struct {
|
||||
JoinRequest
|
||||
User
|
||||
}, 0)
|
||||
for rows.Next() {
|
||||
var r struct {
|
||||
JoinRequest
|
||||
User
|
||||
}
|
||||
err := rows.Scan(
|
||||
&r.JoinRequest.ID, &r.JoinRequest.OrgID, &r.JoinRequest.UserID, &r.JoinRequest.InviteToken, &r.JoinRequest.RequestedAt, &r.JoinRequest.Status,
|
||||
&r.User.ID, &r.User.Email, &r.User.Username, &r.User.DisplayName, &r.User.CreatedAt, &r.User.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requests = append(requests, r)
|
||||
}
|
||||
return requests, rows.Err()
|
||||
}
|
||||
|
||||
// AcceptJoinRequest accepts a join request and adds the user as member
|
||||
func (db *DB) AcceptJoinRequest(ctx context.Context, requestID uuid.UUID, role string) error {
|
||||
// Get the request details
|
||||
var orgID, userID uuid.UUID
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT org_id, user_id
|
||||
FROM join_requests
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`, requestID).Scan(&orgID, &userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add membership
|
||||
err = db.AddMembership(ctx, userID, orgID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark request as accepted
|
||||
_, err = db.ExecContext(ctx, `
|
||||
UPDATE join_requests
|
||||
SET status = 'accepted'
|
||||
WHERE id = $1
|
||||
`, requestID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RejectJoinRequest rejects a join request
|
||||
func (db *DB) RejectJoinRequest(ctx context.Context, requestID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE join_requests
|
||||
SET status = 'rejected'
|
||||
WHERE id = $1
|
||||
`, requestID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetInviteLink returns the invite link token for an org
|
||||
func (db *DB) GetInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) {
|
||||
var token *string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT invite_link_token
|
||||
FROM organizations
|
||||
WHERE id = $1
|
||||
`, orgID).Scan(&token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// RegenerateInviteLink generates a new invite link token
|
||||
func (db *DB) RegenerateInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) {
|
||||
newToken := uuid.New().String()
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE organizations
|
||||
SET invite_link_token = $1
|
||||
WHERE id = $2
|
||||
`, newToken, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &newToken, nil
|
||||
}
|
||||
|
||||
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
orgIDStr := orgID.String()
|
||||
userIDStr := userID.String()
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
|
||||
|
||||
// Basic search and pagination. Returns only direct children of the given path.
|
||||
// For root ("/"), we want files where path doesn't contain "/" after the first character.
|
||||
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
|
||||
FROM files f
|
||||
WHERE f.org_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships m
|
||||
WHERE m.org_id = $1 AND m.user_id = $2
|
||||
)
|
||||
AND f.path != $3
|
||||
AND (
|
||||
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
|
||||
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
|
||||
)
|
||||
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
|
||||
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
|
||||
LIMIT $5 OFFSET $6
|
||||
`, orgID, userID, path, q, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err == nil {
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetAllOrgFilesUnderPath returns all files recursively under the given path for an org
|
||||
func (db *DB) GetAllOrgFilesUnderPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) ([]File, error) {
|
||||
orgIDStr := orgID.String()
|
||||
userIDStr := userID.String()
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=%s, userId=%s, path=%s", orgIDStr, userIDStr, path)
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
|
||||
FROM files f
|
||||
WHERE f.org_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships m
|
||||
WHERE m.org_id = $1 AND m.user_id = $2
|
||||
)
|
||||
AND f.path LIKE $3 || '%'
|
||||
AND f.path != $3
|
||||
ORDER BY f.path
|
||||
`, orgID, userID, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err == nil {
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetUserFiles returns files for a user's personal workspace at a given path
|
||||
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// Return only direct children of the given path
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||
FROM files
|
||||
WHERE user_id = $1
|
||||
AND org_id IS NULL
|
||||
AND path != $2
|
||||
AND (
|
||||
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
||||
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
|
||||
)
|
||||
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
||||
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
|
||||
LIMIT $4 OFFSET $5
|
||||
`, userID, path, q, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err == nil {
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetAllUserFilesUnderPath returns all files recursively under the given path for a user
|
||||
func (db *DB) GetAllUserFilesUnderPath(ctx context.Context, userID uuid.UUID, path string) ([]File, error) {
|
||||
// Return all descendants of the given path
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=, userId=%s, path=%s", userID.String(), path)
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||
FROM files
|
||||
WHERE user_id = $1
|
||||
AND org_id IS NULL
|
||||
AND path LIKE $2 || '%'
|
||||
AND path != $2
|
||||
ORDER BY path
|
||||
`, userID, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err == nil {
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
||||
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
|
||||
var f File
|
||||
var orgIDVal interface{}
|
||||
var userIDVal interface{}
|
||||
orgIDStr := ""
|
||||
userIDStr := ""
|
||||
if orgID != nil {
|
||||
orgIDVal = *orgID
|
||||
orgIDStr = orgID.String()
|
||||
} else {
|
||||
orgIDVal = nil
|
||||
}
|
||||
if userID != nil {
|
||||
userIDVal = *userID
|
||||
userIDStr = userID.String()
|
||||
} else {
|
||||
userIDVal = nil
|
||||
}
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO files (org_id, user_id, name, path, type, size)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// GetFileByID retrieves a file by its ID
|
||||
func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
var modifiedByNull sql.NullString
|
||||
var modifiedByNameNull sql.NullString
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
|
||||
f.modified_by::text, u.display_name
|
||||
FROM files f
|
||||
LEFT JOIN users u ON f.modified_by = u.id
|
||||
WHERE f.id = $1
|
||||
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
|
||||
&modifiedByNull, &modifiedByNameNull)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
if modifiedByNull.Valid {
|
||||
mid, _ := uuid.Parse(modifiedByNull.String)
|
||||
f.ModifiedBy = &mid
|
||||
}
|
||||
if modifiedByNameNull.Valid {
|
||||
f.ModifiedByName = modifiedByNameNull.String
|
||||
}
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// GetOrgFileByPath returns a file by path for an org
|
||||
func (db *DB) GetOrgFileByPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) (*File, error) {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
var modifiedByNull sql.NullString
|
||||
var modifiedByNameNull sql.NullString
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
|
||||
f.modified_by::text, u.display_name
|
||||
FROM files f
|
||||
LEFT JOIN users u ON f.modified_by = u.id
|
||||
WHERE f.org_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships m
|
||||
WHERE m.org_id = $1 AND m.user_id = $2
|
||||
)
|
||||
AND f.path = $3
|
||||
`, orgID, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
|
||||
&modifiedByNull, &modifiedByNameNull)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
if modifiedByNull.Valid {
|
||||
mid, _ := uuid.Parse(modifiedByNull.String)
|
||||
f.ModifiedBy = &mid
|
||||
}
|
||||
if modifiedByNameNull.Valid {
|
||||
f.ModifiedByName = modifiedByNameNull.String
|
||||
}
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// GetUserFileByPath returns a file by path for a user
|
||||
func (db *DB) GetUserFileByPath(ctx context.Context, userID uuid.UUID, path string) (*File, error) {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
var modifiedByNull sql.NullString
|
||||
var modifiedByNameNull sql.NullString
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
|
||||
f.modified_by::text, u.display_name
|
||||
FROM files f
|
||||
LEFT JOIN users u ON f.modified_by = u.id
|
||||
WHERE f.user_id = $1
|
||||
AND f.org_id IS NULL
|
||||
AND f.path = $2
|
||||
`, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
|
||||
&modifiedByNull, &modifiedByNameNull)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
if modifiedByNull.Valid {
|
||||
mid, _ := uuid.Parse(modifiedByNull.String)
|
||||
f.ModifiedBy = &mid
|
||||
}
|
||||
if modifiedByNameNull.Valid {
|
||||
f.ModifiedByName = modifiedByNameNull.String
|
||||
}
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// UpdateFileSize updates the size, modification time, and modifier of a file
|
||||
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE files
|
||||
SET size = $1, last_modified = NOW(), modified_by = $3
|
||||
WHERE id = $2
|
||||
`, size, fileID, modifiedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateFilePath updates the path and name of a file while preserving its ID
|
||||
// This is used when moving/renaming files to ensure WOPI sessions remain valid
|
||||
func (db *DB) UpdateFilePath(ctx context.Context, fileID uuid.UUID, newName, newPath string) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE files
|
||||
SET name = $1, path = $2, last_modified = NOW()
|
||||
WHERE id = $3
|
||||
`, newName, newPath, fileID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFileByPath removes a file or folder matching path for a given org or user
|
||||
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
|
||||
var res sql.Result
|
||||
var err error
|
||||
if orgID != nil {
|
||||
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
|
||||
} else if userID != nil {
|
||||
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = res.RowsAffected()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Passkey-related methods
|
||||
|
||||
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
|
||||
@@ -391,3 +1160,113 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
|
||||
`, challenge)
|
||||
return err
|
||||
}
|
||||
|
||||
// FileShareLink methods
|
||||
|
||||
// CreateFileShareLink creates a new share link for a file
|
||||
func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID uuid.UUID, orgID *uuid.UUID, createdByUserID uuid.UUID) (*models.FileShareLink, error) {
|
||||
var link models.FileShareLink
|
||||
var expiresAtNull sql.NullTime
|
||||
var orgIDNull sql.NullString
|
||||
// If caller didn't provide an orgID, try to infer it from the file record
|
||||
if orgID == nil {
|
||||
var fileOrgNull sql.NullString
|
||||
fileErr := db.QueryRowContext(ctx, `SELECT org_id::text FROM files WHERE id = $1`, fileID).Scan(&fileOrgNull)
|
||||
if fileErr == nil && fileOrgNull.Valid {
|
||||
if parsed, perr := uuid.Parse(fileOrgNull.String); perr == nil {
|
||||
orgID = &parsed
|
||||
}
|
||||
}
|
||||
// If the file lookup failed or org_id is not set, orgID remains nil
|
||||
}
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||
`, token, fileID, orgID, createdByUserID).Scan(
|
||||
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
|
||||
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgIDNull.Valid {
|
||||
parsed, err := uuid.Parse(orgIDNull.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link.OrgID = &parsed
|
||||
}
|
||||
if expiresAtNull.Valid {
|
||||
link.ExpiresAt = &expiresAtNull.Time
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// GetFileShareLinkByFileID gets the active share link for a file
|
||||
func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) {
|
||||
var link models.FileShareLink
|
||||
var expiresAtNull sql.NullTime
|
||||
var orgIDNull sql.NullString
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||
FROM file_share_links
|
||||
WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, fileID).Scan(
|
||||
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
|
||||
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgIDNull.Valid {
|
||||
parsed, err := uuid.Parse(orgIDNull.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link.OrgID = &parsed
|
||||
}
|
||||
if expiresAtNull.Valid {
|
||||
link.ExpiresAt = &expiresAtNull.Time
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// GetFileShareLinkByToken gets a share link by token
|
||||
func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) {
|
||||
var link models.FileShareLink
|
||||
var expiresAtNull sql.NullTime
|
||||
var orgIDNull sql.NullString
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||
FROM file_share_links
|
||||
WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
|
||||
`, token).Scan(
|
||||
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
|
||||
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgIDNull.Valid {
|
||||
parsed, err := uuid.Parse(orgIDNull.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link.OrgID = &parsed
|
||||
}
|
||||
if expiresAtNull.Valid {
|
||||
link.ExpiresAt = &expiresAtNull.Time
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// RevokeFileShareLink revokes a share link
|
||||
func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE file_share_links
|
||||
SET is_revoked = TRUE, updated_at = NOW()
|
||||
WHERE file_id = $1 AND is_revoked = FALSE
|
||||
`, fileID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -14,10 +14,15 @@ import (
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||
// More specific authentication error codes
|
||||
CodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
|
||||
CodeInvalidPassword ErrorCode = "INVALID_PASSWORD"
|
||||
|
||||
CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
|
||||
CodeNotFound ErrorCode = "NOT_FOUND"
|
||||
CodeConflict ErrorCode = "CONFLICT"
|
||||
CodeAlreadyExists ErrorCode = "ALREADY_EXISTS"
|
||||
CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
|
||||
CodeInternal ErrorCode = "INTERNAL"
|
||||
)
|
||||
@@ -40,7 +45,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
|
||||
|
||||
// GetRequestID extracts the request ID from the request context
|
||||
func GetRequestID(r *http.Request) string {
|
||||
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
||||
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
||||
return reqID
|
||||
}
|
||||
return "unknown"
|
||||
@@ -48,10 +53,10 @@ func GetRequestID(r *http.Request) string {
|
||||
|
||||
// GetUserID extracts user ID from context if available
|
||||
func GetUserID(r *http.Request) string {
|
||||
if userID := r.Context().Value("user"); userID != nil {
|
||||
if uid, ok := userID.(string); ok {
|
||||
return uid
|
||||
}
|
||||
// Use type contextKey matching middleware package
|
||||
type contextKey string
|
||||
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
|
||||
return userID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
846
go_cloud/internal/http/wopi_handlers.go
Normal file
@@ -0,0 +1,846 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/config"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"go.b0esche.cloud/backend/internal/errors"
|
||||
"go.b0esche.cloud/backend/internal/middleware"
|
||||
"go.b0esche.cloud/backend/internal/models"
|
||||
"go.b0esche.cloud/backend/internal/storage"
|
||||
"go.b0esche.cloud/backend/pkg/jwt"
|
||||
)
|
||||
|
||||
// Collabora discovery cache
|
||||
var (
|
||||
collaboraEditorURL string
|
||||
collaboraDiscoveryCache time.Time
|
||||
collaboraDiscoveryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getCollaboraEditorURL fetches the editor URL from Collabora's discovery endpoint
|
||||
func getCollaboraEditorURL(collaboraBaseURL string) string {
|
||||
collaboraDiscoveryMu.RLock()
|
||||
// Cache for 5 minutes
|
||||
if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute {
|
||||
url := collaboraEditorURL
|
||||
collaboraDiscoveryMu.RUnlock()
|
||||
return url
|
||||
}
|
||||
collaboraDiscoveryMu.RUnlock()
|
||||
|
||||
// Fetch discovery
|
||||
collaboraDiscoveryMu.Lock()
|
||||
defer collaboraDiscoveryMu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute {
|
||||
return collaboraEditorURL
|
||||
}
|
||||
|
||||
discoveryURL := collaboraBaseURL + "/hosting/discovery"
|
||||
resp, err := http.Get(discoveryURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[COLLABORA] Failed to fetch discovery: %v\n", err)
|
||||
// Fallback to guessed URL
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("[COLLABORA] Failed to read discovery: %v\n", err)
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
|
||||
// Parse XML to extract urlsrc
|
||||
type Action struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Ext string `xml:"ext,attr"`
|
||||
URLSrc string `xml:"urlsrc,attr"`
|
||||
}
|
||||
type App struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Actions []Action `xml:"action"`
|
||||
}
|
||||
type NetZone struct {
|
||||
Apps []App `xml:"app"`
|
||||
}
|
||||
type WopiDiscovery struct {
|
||||
NetZone NetZone `xml:"net-zone"`
|
||||
}
|
||||
|
||||
var discovery WopiDiscovery
|
||||
if err := xml.Unmarshal(body, &discovery); err != nil {
|
||||
fmt.Printf("[COLLABORA] Failed to parse discovery XML: %v\n", err)
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
|
||||
// Find the first edit action URL (they all have the same base)
|
||||
for _, app := range discovery.NetZone.Apps {
|
||||
for _, action := range app.Actions {
|
||||
if action.URLSrc != "" {
|
||||
// Extract base URL (remove query string marker)
|
||||
url := strings.TrimSuffix(action.URLSrc, "?")
|
||||
collaboraEditorURL = url
|
||||
collaboraDiscoveryCache = time.Now()
|
||||
fmt.Printf("[COLLABORA] Discovered editor URL: %s\n", url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[COLLABORA] No editor URL found in discovery\n")
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
|
||||
// WOPILockManager manages file locks to prevent concurrent editing conflicts
|
||||
type WOPILockManager struct {
|
||||
locks map[string]*models.WOPILockInfo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var lockManager = &WOPILockManager{
|
||||
locks: make(map[string]*models.WOPILockInfo),
|
||||
}
|
||||
|
||||
// AcquireLock tries to acquire a lock for a file
|
||||
func (m *WOPILockManager) AcquireLock(fileID, userID string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if existing, ok := m.locks[fileID]; ok {
|
||||
// Check if lock has expired
|
||||
if time.Now().Before(existing.ExpiresAt) {
|
||||
// Lock still active - check if same user
|
||||
if existing.UserID != userID {
|
||||
fmt.Printf("[WOPI-LOCK] Lock conflict: file=%s locked_by=%s requested_by=%s\n", fileID, existing.UserID, userID)
|
||||
return "", fmt.Errorf("file locked by another user")
|
||||
}
|
||||
// Same user, refresh the lock
|
||||
lockID := uuid.New().String()
|
||||
m.locks[fileID] = &models.WOPILockInfo{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
LockID: lockID,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
fmt.Printf("[WOPI-LOCK] Lock refreshed: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
|
||||
return lockID, nil
|
||||
}
|
||||
// Lock expired, remove it
|
||||
delete(m.locks, fileID)
|
||||
}
|
||||
|
||||
// Acquire new lock
|
||||
lockID := uuid.New().String()
|
||||
m.locks[fileID] = &models.WOPILockInfo{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
LockID: lockID,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
fmt.Printf("[WOPI-LOCK] Lock acquired: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
|
||||
return lockID, nil
|
||||
}
|
||||
|
||||
// ReleaseLock releases a lock for a file
|
||||
func (m *WOPILockManager) ReleaseLock(fileID, userID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if lock, ok := m.locks[fileID]; ok {
|
||||
if lock.UserID == userID {
|
||||
delete(m.locks, fileID)
|
||||
fmt.Printf("[WOPI-LOCK] Lock released: file=%s user=%s\n", fileID, userID)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lock held by different user")
|
||||
}
|
||||
return fmt.Errorf("no lock found")
|
||||
}
|
||||
|
||||
// GetLock returns the current lock info for a file
|
||||
func (m *WOPILockManager) GetLock(fileID string) *models.WOPILockInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if lock, ok := m.locks[fileID]; ok {
|
||||
// Check if expired
|
||||
if time.Now().Before(lock.ExpiresAt) {
|
||||
return lock
|
||||
}
|
||||
// Expired, will be cleaned up on next acquire attempt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateWOPIAccessToken validates a WOPI access token
|
||||
func validateWOPIAccessToken(tokenString string, jwtManager *jwt.Manager) (*jwt.Claims, error) {
|
||||
claims, err := jwtManager.Validate(tokenString)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-TOKEN] Token validation failed: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if time.Now().After(claims.ExpiresAt.Time) {
|
||||
fmt.Printf("[WOPI-TOKEN] Token expired: user=%s\n", claims.UserID)
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-TOKEN] Token validated: user=%s expires=%v\n", claims.UserID, claims.ExpiresAt.Time)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// WOPICheckFileInfoHandler handles GET /wopi/files/{fileId}
|
||||
// Returns metadata about the file and user permissions
|
||||
func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from query parameter
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
if accessToken == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Get file info from database
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] File not found: file=%s error=%v\n", fileID, err)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-CheckFileInfo] START: file=%s user=%s size=%d path=%s\n", fileID, userID.String(), file.Size, file.Path)
|
||||
|
||||
// Get user info for UserFriendlyName
|
||||
user, err := db.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] Failed to get user info: user=%s error=%v\n", userID.String(), err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to get user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this file
|
||||
canAccess := false
|
||||
var ownerID string
|
||||
|
||||
// Prefer org ownership when file belongs to an org and the user is a member
|
||||
if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
if err == nil && member != nil {
|
||||
canAccess = true
|
||||
ownerID = file.OrgID.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to per-user file ownership
|
||||
if !canAccess && file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
ownerID = userID.String()
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure LastModifiedTime is not zero
|
||||
lastModifiedTime := file.LastModified
|
||||
if lastModifiedTime.IsZero() {
|
||||
lastModifiedTime = file.CreatedAt
|
||||
}
|
||||
if lastModifiedTime.IsZero() {
|
||||
lastModifiedTime = time.Now()
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := models.WOPICheckFileInfoResponse{
|
||||
BaseFileName: file.Name,
|
||||
Size: file.Size,
|
||||
Version: file.ID.String(),
|
||||
OwnerId: ownerID,
|
||||
UserId: userID.String(),
|
||||
UserFriendlyName: user.DisplayName,
|
||||
UserCanWrite: true,
|
||||
UserCanRename: false,
|
||||
UserCanNotWriteRelative: false,
|
||||
ReadOnly: false,
|
||||
RestrictedWebViewOnly: false,
|
||||
UserCanCreateRelativeToFolder: false,
|
||||
EnableOwnerTermination: false,
|
||||
SupportsUpdate: true,
|
||||
SupportsCobalt: false,
|
||||
SupportsLocks: true,
|
||||
SupportsExtendedLockLength: false,
|
||||
SupportsGetLock: true,
|
||||
SupportsDelete: false,
|
||||
SupportsRename: false,
|
||||
SupportsRenameRelativeToFolder: false,
|
||||
SupportsFolders: false,
|
||||
SupportsScenarios: []string{"default"},
|
||||
LastModifiedTime: lastModifiedTime.UTC().Format(time.RFC3339),
|
||||
IsAnonymousUser: false,
|
||||
TimeZone: "UTC",
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-REQUEST] CheckFileInfo: file=%s user=%s size=%d\n", fileID, userID.String(), file.Size)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// WOPIGetFileHandler handles GET /wopi/files/{fileId}/contents
|
||||
// Downloads the document file content
|
||||
func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-GetFile] START: file=%s\n", fileID)
|
||||
|
||||
// Get access token from query parameter
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
if accessToken == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Get file info from database
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] GetFile - File not found: file=%s error=%v\n", fileID, err)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this file
|
||||
canAccess := false
|
||||
var webDAVClient *storage.WebDAVClient
|
||||
var remotePath string
|
||||
|
||||
// Prefer org storage when present and the user is a member
|
||||
if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
if err == nil && member != nil {
|
||||
canAccess = true
|
||||
// Use user's WebDAV client for org files too
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Org files: stored under /orgs/{orgID}/ prefix
|
||||
rel := strings.TrimPrefix(file.Path, "/")
|
||||
remotePath = path.Join("/orgs", file.OrgID.String(), rel)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to per-user files
|
||||
if !canAccess && file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// User files: path is relative to user's WebDAV root
|
||||
remotePath = file.Path
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
fmt.Printf("[WOPI-REQUEST] GetFile - Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Download file from storage
|
||||
fmt.Printf("[WOPI-STORAGE] GetFile downloading: file=%s remotePath=%s\n", fileID, remotePath)
|
||||
resp, err := webDAVClient.Download(r.Context(), remotePath, "")
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to download file: file=%s path=%s error=%v\n", fileID, file.Path, err)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found in storage", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fmt.Printf("[WOPI-STORAGE] Download response status: %d\n", resp.StatusCode)
|
||||
|
||||
// Set response headers
|
||||
contentType := getMimeType(file.Name)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
fmt.Printf("[WOPI-STORAGE] GetFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), file.Size)
|
||||
|
||||
// Stream file content
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents
|
||||
// Uploads edited document back to storage
|
||||
func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Get file info from database
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] PutFile - File not found: file=%s\n", fileID)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this file
|
||||
canAccess := false
|
||||
var webDAVClient *storage.WebDAVClient
|
||||
var remotePath string
|
||||
|
||||
// Prefer org storage when present and the user is a member
|
||||
if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
if err == nil && member != nil {
|
||||
canAccess = true
|
||||
// Use user's WebDAV client for org files too
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Org files: stored under /orgs/{orgID}/ prefix
|
||||
rel := strings.TrimPrefix(file.Path, "/")
|
||||
remotePath = path.Join("/orgs", file.OrgID.String(), rel)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to per-user files
|
||||
if !canAccess && file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// User files: path is relative to user's WebDAV root
|
||||
remotePath = file.Path
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
fmt.Printf("[WOPI-REQUEST] PutFile - Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check lock
|
||||
lock := lockManager.GetLock(fileID)
|
||||
if lock != nil && lock.UserID != userID.String() {
|
||||
fmt.Printf("[WOPI-LOCK] Put conflict: file=%s locked_by=%s user=%s\n", fileID, lock.UserID, userID.String())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Read file content from request body
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to read request body: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to read content", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Upload to storage
|
||||
fmt.Printf("[WOPI-STORAGE] PutFile uploading: file=%s remotePath=%s\n", fileID, remotePath)
|
||||
err = webDAVClient.Upload(r.Context(), remotePath, strings.NewReader(string(content)), int64(len(content)))
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to upload file: file=%s path=%s error=%v\n", fileID, file.Path, err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update file size and modification time in database
|
||||
newSize := int64(len(content))
|
||||
err = db.UpdateFileSize(r.Context(), fileUUID, newSize, &userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err)
|
||||
// Don't fail the upload, just log the warning
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-STORAGE] PutFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), newSize)
|
||||
|
||||
// Return response
|
||||
response := models.WOPIPutFileResponse{
|
||||
ItemVersion: fileUUID.String(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// WOPILockHandler handles POST /wopi/files/{fileId} with X-WOPI-Override header for lock operations
|
||||
func wopiLockHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims.UserID
|
||||
override := r.Header.Get("X-WOPI-Override")
|
||||
|
||||
// Get file to verify access
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify access
|
||||
canAccess := false
|
||||
if file.UserID != nil && file.UserID.String() == userID {
|
||||
canAccess = true
|
||||
} else if file.OrgID != nil {
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userUUID)
|
||||
canAccess = (err == nil && member != nil)
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle lock operations
|
||||
switch override {
|
||||
case "LOCK":
|
||||
// Acquire lock
|
||||
lockID, err := lockManager.AcquireLock(fileID, userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-LOCK] Lock acquisition failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-WOPI-LockID", lockID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
|
||||
case "UNLOCK":
|
||||
// Release lock
|
||||
err := lockManager.ReleaseLock(fileID, userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-LOCK] Lock release failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
|
||||
case "GET_LOCK":
|
||||
// Get lock info
|
||||
lock := lockManager.GetLock(fileID)
|
||||
if lock == nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-WOPI-LockID", lock.LockID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
|
||||
default:
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Unknown X-WOPI-Override value", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// WOPISessionHandler handles POST /user/files/{fileId}/wopi-session and /orgs/{orgId}/files/{fileId}/wopi-session
|
||||
// Returns WOPISrc URL and access token for opening document in Collabora
|
||||
func wopiSessionHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context (from auth middleware)
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
// Get file info
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify access
|
||||
canAccess := false
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
} else if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
canAccess = (err == nil && member != nil)
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate WOPI access token (1 hour duration)
|
||||
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build WOPISrc URL
|
||||
wopisrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken)
|
||||
|
||||
response := models.WOPISessionResponse{
|
||||
WOPISrc: wopisrc,
|
||||
AccessToken: accessToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
fmt.Printf("[WOPI-REQUEST] Session created: file=%s user=%s\n", fileID, userID.String())
|
||||
}
|
||||
|
||||
// CollaboraProxyHandler serves an HTML page that POSTs WOPISrc to Collabora
|
||||
// This avoids CORS issues by having the POST originate from our domain
|
||||
func collaboraProxyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context (from auth middleware)
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file info
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify access
|
||||
canAccess := false
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
} else if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
canAccess = (err == nil && member != nil)
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate WOPI access token (1 hour duration)
|
||||
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build WOPISrc URL (without access_token - that goes in a separate form field)
|
||||
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s", fileID)
|
||||
|
||||
// Get the correct Collabora editor URL from discovery (includes version hash)
|
||||
editorURL := getCollaboraEditorURL(collaboraURL)
|
||||
|
||||
// URL-encode the WOPISrc for use in the form action URL
|
||||
encodedWopiSrc := url.QueryEscape(wopiSrc)
|
||||
|
||||
// Build the full Collabora URL with WOPISrc as query parameter
|
||||
// Collabora expects: cool.html?WOPISrc=<encoded-url>
|
||||
collaboraFullURL := fmt.Sprintf("%s?WOPISrc=%s", editorURL, encodedWopiSrc)
|
||||
|
||||
// Return HTML page with auto-submitting form
|
||||
// The form POSTs to Collabora with access_token in the body
|
||||
// WOPISrc must be in the URL as a query parameter
|
||||
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Loading Document...</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%%; height: 100%%; overflow: hidden; }
|
||||
.loading { position: fixed; top: 50%%; left: 50%%; transform: translate(-50%%, -50%%); text-align: center; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.loading p { color: #666; margin-top: 10px; font-family: system-ui, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading">
|
||||
<p>Loading Collabora Online...</p>
|
||||
</div>
|
||||
<form method="POST" action="%s" target="_self" id="collaboraForm" style="display: none;">
|
||||
<input type="hidden" id="access_token" name="access_token" value="%s">
|
||||
</form>
|
||||
<script>
|
||||
// Auto-submit the form to Collabora
|
||||
var form = document.getElementById('collaboraForm');
|
||||
if (form) {
|
||||
console.log('[COLLABORA] Submitting form to %s');
|
||||
form.submit();
|
||||
} else {
|
||||
console.error('[COLLABORA] Form not found');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`, collaboraFullURL, accessToken, collaboraFullURL)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Don't set X-Frame-Options - this endpoint is meant to be loaded in an iframe
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(htmlContent))
|
||||
|
||||
fmt.Printf("[COLLABORA-PROXY] Served HTML form: file=%s user=%s wopi_src=%s editor_url=%s\n", fileID, userID.String(), wopiSrc, collaboraFullURL)
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/audit"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
@@ -21,41 +25,247 @@ var RequestID = middleware.RequestID
|
||||
var Logger = middleware.Logger
|
||||
var Recoverer = middleware.Recoverer
|
||||
|
||||
// TODO: Implement rate limiter
|
||||
// SecurityHeaders adds security-related HTTP headers
|
||||
func SecurityHeaders() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent MIME type sniffing
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// Prevent clickjacking - allow for WOPI routes
|
||||
if !strings.HasPrefix(r.URL.Path, "/wopi") && !strings.HasPrefix(r.URL.Path, "/user/files/") && !strings.HasPrefix(r.URL.Path, "/orgs/") {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
}
|
||||
// Enable XSS protection
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
// Referrer policy
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// Content Security Policy - basic policy
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://go.b0esche.cloud https://of.b0esche.cloud; frame-src 'self' https://of.b0esche.cloud;")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CORS middleware - accepts allowedOrigins comma-separated string
|
||||
func CORS(allowedOrigins string) func(http.Handler) http.Handler {
|
||||
allowedList, allowAll := compileAllowedOrigins(allowedOrigins)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && isOriginAllowed(origin, allowedList) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
} else if allowAll {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
allowHeaders := []string{"Content-Type", "Authorization", "Range", "Accept", "Origin", "X-Requested-With"}
|
||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
allowHeaders = append(allowHeaders, reqHeaders)
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", "))
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition, Content-Range, Accept-Ranges")
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compileAllowedOrigins(origins string) ([]string, bool) {
|
||||
var allowed []string
|
||||
allowAll := false
|
||||
|
||||
for _, origin := range strings.Split(origins, ",") {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "*" {
|
||||
allowAll = true
|
||||
}
|
||||
allowed = append(allowed, trimmed)
|
||||
}
|
||||
|
||||
if len(allowed) == 0 && !allowAll {
|
||||
allowAll = true
|
||||
}
|
||||
|
||||
return allowed, allowAll
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, v := range values {
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isOriginAllowed(origin string, allowed []string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range allowed {
|
||||
if originMatches(origin, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func originMatches(origin, pattern string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return strings.EqualFold(origin, pattern)
|
||||
}
|
||||
regexPattern := "(?i)^" + regexp.QuoteMeta(pattern) + "$"
|
||||
regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*")
|
||||
matched, err := regexp.MatchString(regexPattern, origin)
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
// rateLimiter tracks request counts per IP address
|
||||
type rateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]*clientRequests
|
||||
}
|
||||
|
||||
type clientRequests struct {
|
||||
count int
|
||||
resetTime time.Time
|
||||
}
|
||||
|
||||
var limiter = &rateLimiter{
|
||||
requests: make(map[string]*clientRequests),
|
||||
}
|
||||
|
||||
// RateLimit implements a simple sliding window rate limiter
|
||||
// Limits: 100 requests per minute per IP for general endpoints
|
||||
// 10 requests per minute per IP for auth endpoints
|
||||
var RateLimit = func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Basic rate limiting logic here
|
||||
// Get client IP (consider X-Forwarded-For from reverse proxy)
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
|
||||
// Determine rate limit based on endpoint
|
||||
limit := 100 // Default: 100 requests/minute
|
||||
if strings.HasPrefix(r.URL.Path, "/auth/") {
|
||||
limit = 10 // Auth endpoints: 10 requests/minute
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
client, exists := limiter.requests[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !exists || now.After(client.resetTime) {
|
||||
// New window
|
||||
limiter.requests[ip] = &clientRequests{
|
||||
count: 1,
|
||||
resetTime: now.Add(time.Minute),
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if client.count >= limit {
|
||||
limiter.mu.Unlock()
|
||||
w.Header().Set("Retry-After", "60")
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
client.count++
|
||||
limiter.mu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
userKey contextKey = "user"
|
||||
sessionKey contextKey = "session"
|
||||
orgKey contextKey = "org"
|
||||
UserKey ContextKey = "user"
|
||||
SessionKey ContextKey = "session"
|
||||
TokenKey ContextKey = "token"
|
||||
OrgKey ContextKey = "org"
|
||||
)
|
||||
|
||||
// GetUserID retrieves the user ID from the request context
|
||||
func GetUserID(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(UserKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
// GetSession retrieves the session from the request context
|
||||
func GetSession(ctx context.Context) (*database.Session, bool) {
|
||||
session, ok := ctx.Value(SessionKey).(*database.Session)
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// GetToken retrieves the JWT token from the request context
|
||||
func GetToken(ctx context.Context) (string, bool) {
|
||||
token, ok := ctx.Value(TokenKey).(string)
|
||||
return token, ok
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
var tokenString string
|
||||
var tokenSource string
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
tokenSource = "header"
|
||||
} else {
|
||||
// Fallback to query parameter token (for viewers that cannot set headers)
|
||||
qToken := r.URL.Query().Get("token")
|
||||
if qToken == "" {
|
||||
fmt.Printf("[AUTH-TOKEN] source=none, path=%s, statusCode=401\n", r.RequestURI)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tokenString = qToken
|
||||
tokenSource = "query"
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
fmt.Printf("[AUTH-TOKEN] source=%s, path=%s\n", tokenSource, r.RequestURI)
|
||||
|
||||
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||
if err != nil {
|
||||
fmt.Printf("[AUTH-TOKEN] validation_failed, source=%s, path=%s, error=%v\n", tokenSource, r.RequestURI, err)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, sessionKey, session)
|
||||
fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, SessionKey, session)
|
||||
ctx = context.WithValue(ctx, TokenKey, tokenString)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -65,7 +275,7 @@ func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Hand
|
||||
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value(userKey).(string)
|
||||
userIDStr := r.Context().Value(UserKey).(string)
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
orgIDStr := r.Header.Get("X-Org-ID")
|
||||
@@ -91,20 +301,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
||||
return
|
||||
}
|
||||
|
||||
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
|
||||
if err != nil {
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "org_access",
|
||||
Success: false,
|
||||
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
|
||||
})
|
||||
errors.LogError(r, err, "Org access denied")
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), orgKey, orgID)
|
||||
ctx := context.WithValue(r.Context(), OrgKey, orgID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -114,9 +311,9 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
||||
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value(userKey).(string)
|
||||
userIDStr := r.Context().Value(UserKey).(string)
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
orgID := r.Context().Value(orgKey).(uuid.UUID)
|
||||
orgID := r.Context().Value(OrgKey).(uuid.UUID)
|
||||
|
||||
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
|
||||
if err != nil || !hasPerm {
|
||||
|
||||
20
go_cloud/internal/models/file_share_link.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileShareLink represents a public share link for a file
|
||||
type FileShareLink struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Token string `json:"token" db:"token"`
|
||||
FileID uuid.UUID `json:"file_id" db:"file_id"`
|
||||
OrgID *uuid.UUID `json:"org_id,omitempty" db:"org_id"`
|
||||
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||
IsRevoked bool `json:"is_revoked" db:"is_revoked"`
|
||||
}
|
||||
72
go_cloud/internal/models/wopi.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// WOPICheckFileInfoResponse represents the response to WOPI CheckFileInfo request
|
||||
// Reference: https://docs.microsoft.com/en-us/openspecs/office_protocols/ms-wopi/4b8ffc3f-e8a6-4169-8c4e-34924ac6ae2f
|
||||
type WOPICheckFileInfoResponse struct {
|
||||
BaseFileName string `json:"BaseFileName"`
|
||||
Size int64 `json:"Size"`
|
||||
Version string `json:"Version"`
|
||||
OwnerId string `json:"OwnerId"`
|
||||
UserId string `json:"UserId"`
|
||||
UserFriendlyName string `json:"UserFriendlyName"`
|
||||
UserCanWrite bool `json:"UserCanWrite"`
|
||||
UserCanRename bool `json:"UserCanRename"`
|
||||
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
|
||||
ReadOnly bool `json:"ReadOnly"`
|
||||
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
|
||||
UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"`
|
||||
EnableOwnerTermination bool `json:"EnableOwnerTermination"`
|
||||
SupportsUpdate bool `json:"SupportsUpdate"`
|
||||
SupportsCobalt bool `json:"SupportsCobalt"`
|
||||
SupportsLocks bool `json:"SupportsLocks"`
|
||||
SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"`
|
||||
SupportsGetLock bool `json:"SupportsGetLock"`
|
||||
SupportsDelete bool `json:"SupportsDelete"`
|
||||
SupportsRename bool `json:"SupportsRename"`
|
||||
SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"`
|
||||
SupportsFolders bool `json:"SupportsFolders"`
|
||||
SupportsScenarios []string `json:"SupportsScenarios"`
|
||||
LastModifiedTime string `json:"LastModifiedTime"`
|
||||
IsAnonymousUser bool `json:"IsAnonymousUser"`
|
||||
TimeZone string `json:"TimeZone"`
|
||||
CloseUrl string `json:"CloseUrl,omitempty"`
|
||||
EditUrl string `json:"EditUrl,omitempty"`
|
||||
ViewUrl string `json:"ViewUrl,omitempty"`
|
||||
FileSharingUrl string `json:"FileSharingUrl,omitempty"`
|
||||
DownloadUrl string `json:"DownloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
// WOPIPutFileResponse represents the response to WOPI PutFile request
|
||||
type WOPIPutFileResponse struct {
|
||||
ItemVersion string `json:"ItemVersion"`
|
||||
}
|
||||
|
||||
// WOPILockInfo represents information about a file lock
|
||||
type WOPILockInfo struct {
|
||||
FileID string `json:"file_id"`
|
||||
UserID string `json:"user_id"`
|
||||
LockID string `json:"lock_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// WOPIAccessTokenRequest represents a request to get WOPI access token
|
||||
type WOPIAccessTokenRequest struct {
|
||||
FileID string `json:"file_id"`
|
||||
}
|
||||
|
||||
// WOPIAccessTokenResponse represents a response with WOPI access token
|
||||
type WOPIAccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessTokenTTL int64 `json:"access_token_ttl"`
|
||||
BootstrapperUrl string `json:"bootstrapper_url,omitempty"`
|
||||
ClosePostMessage bool `json:"close_post_message"`
|
||||
}
|
||||
|
||||
// WOPISessionResponse represents a response for creating a WOPI session
|
||||
type WOPISessionResponse struct {
|
||||
WOPISrc string `json:"wopi_src"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgconn"
|
||||
)
|
||||
|
||||
// ResolveUserOrgs returns the organizations a user belongs to
|
||||
@@ -24,17 +28,58 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
|
||||
|
||||
// CreateOrg creates a new organization and adds the user as owner
|
||||
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
|
||||
if slug == "" {
|
||||
// Simple slug generation
|
||||
slug = name // TODO: make URL safe
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if trimmedName == "" {
|
||||
return nil, fmt.Errorf("organization name cannot be empty")
|
||||
}
|
||||
|
||||
baseSlug := slugify(slug)
|
||||
if baseSlug == "" {
|
||||
baseSlug = slugify(trimmedName)
|
||||
}
|
||||
if baseSlug == "" {
|
||||
baseSlug = fmt.Sprintf("org-%s", uuid.NewString()[:8])
|
||||
}
|
||||
|
||||
var org *database.Organization
|
||||
var err error
|
||||
// Try a handful of suffixes on unique constraint violation
|
||||
for i := 0; i < 5; i++ {
|
||||
candidate := baseSlug
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
|
||||
}
|
||||
org, err = db.CreateOrg(ctx, userID, trimmedName, candidate)
|
||||
if err != nil {
|
||||
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
|
||||
// Unique violation; try next suffix
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
org, err := db.CreateOrg(ctx, name, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = db.AddMembership(ctx, userID, org.ID, "owner")
|
||||
if err != nil {
|
||||
|
||||
if err = db.AddMembership(ctx, userID, org.ID, "owner"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// slugify converts a string to a URL-safe slug with hyphens.
|
||||
func slugify(s string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(s))
|
||||
if lower == "" {
|
||||
return ""
|
||||
}
|
||||
// Replace non-alphanumeric with hyphen
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug := re.ReplaceAllString(lower, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
// Collapse multiple hyphens
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
return slug
|
||||
}
|
||||
|
||||
@@ -22,9 +22,8 @@ const (
|
||||
|
||||
var rolePermissions = map[string][]Permission{
|
||||
"owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
|
||||
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit},
|
||||
"editor": {FileRead, FileWrite, DocumentView, DocumentEdit},
|
||||
"viewer": {FileRead, DocumentView},
|
||||
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
|
||||
"member": {FileRead, DocumentView},
|
||||
}
|
||||
|
||||
// HasPermission checks if user has permission in org
|
||||
|
||||
78
go_cloud/internal/storage/nextcloud.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateNextcloudUser creates a new Nextcloud user account via OCS API
|
||||
func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, password string) error {
|
||||
// Remove any path from base URL, we need just the scheme://host:port
|
||||
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
|
||||
urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL)
|
||||
|
||||
// OCS API expects form-encoded data with proper URL encoding
|
||||
formData := url.Values{
|
||||
"userid": {username},
|
||||
"password": {password},
|
||||
}.Encode()
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(adminUser, adminPass)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 200 = success, 409 = user already exists (which is fine)
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 409 {
|
||||
return fmt.Errorf("failed to create Nextcloud user (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
log.Printf("[NEXTCLOUD] Created user account: %s with generated password\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSecurePassword generates a random secure password
|
||||
func GenerateSecurePassword(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
// NewUserWebDAVClient creates a WebDAV client for a specific user
|
||||
func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVClient {
|
||||
// Use internal Nextcloud URL to bypass Traefik timeouts
|
||||
baseURL := "http://nextcloud"
|
||||
// Build the full WebDAV URL for this user
|
||||
fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username)
|
||||
|
||||
return &WebDAVClient{
|
||||
BaseURL: fullURL,
|
||||
user: username,
|
||||
pass: password,
|
||||
basePrefix: "/",
|
||||
httpClient: &http.Client{Timeout: 10 * time.Minute},
|
||||
}
|
||||
}
|
||||
296
go_cloud/internal/storage/webdav.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/config"
|
||||
)
|
||||
|
||||
type WebDAVClient struct {
|
||||
BaseURL string
|
||||
user string
|
||||
pass string
|
||||
basePrefix string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewWebDAVClient returns nil if no Nextcloud URL configured
|
||||
func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
|
||||
if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" {
|
||||
log.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n")
|
||||
return nil
|
||||
}
|
||||
u := strings.TrimRight(cfg.NextcloudURL, "/")
|
||||
if !strings.Contains(u, "/remote.php") {
|
||||
u += "/remote.php/dav/files/" + cfg.NextcloudUser
|
||||
}
|
||||
base := cfg.NextcloudBase
|
||||
if base == "" {
|
||||
base = "/"
|
||||
}
|
||||
log.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base)
|
||||
return &WebDAVClient{
|
||||
BaseURL: u,
|
||||
user: cfg.NextcloudUser,
|
||||
pass: cfg.NextcloudPass,
|
||||
basePrefix: strings.TrimRight(base, "/"),
|
||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ensureParent creates intermediate collections using MKCOL. Ignoring errors when already exists.
|
||||
func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) error {
|
||||
// build incremental paths
|
||||
dir := path.Dir(remotePath)
|
||||
if dir == "." || dir == "/" || dir == "" {
|
||||
return nil
|
||||
}
|
||||
// split and build prefixes
|
||||
parts := strings.Split(strings.Trim(dir, "/"), "/")
|
||||
cur := c.basePrefix
|
||||
for _, p := range parts {
|
||||
cur = path.Join(cur, p)
|
||||
var mkurl string
|
||||
// Always ensure a single '/' between BaseURL and the current path
|
||||
// e.g. http://nextcloud/remote.php/dav/files/testuser/orgs/<id>
|
||||
mkurl = fmt.Sprintf("%s/%s", strings.TrimRight(c.BaseURL, "/"), strings.TrimLeft(cur, "/"))
|
||||
req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil)
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Read body for diagnostics
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
// 201 created, 405 exists — ignore
|
||||
if resp.StatusCode == 201 || resp.StatusCode == 405 {
|
||||
continue
|
||||
}
|
||||
// Any other status is an error: return with diagnostics so caller can log and act on it
|
||||
return fmt.Errorf("MKCOL failed for %s: status=%d body=%s", mkurl, resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload streams the content to the remotePath using HTTP PUT (WebDAV). remotePath should be absolute under basePrefix.
|
||||
func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reader, size int64) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
// Ensure parent collections, skip for .avatars as it should exist
|
||||
if !strings.HasPrefix(remotePath, ".avatars/") {
|
||||
if err := c.ensureParent(ctx, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Construct URL
|
||||
// remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
var full string
|
||||
if u == "" {
|
||||
full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
|
||||
} else {
|
||||
full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
|
||||
}
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
fmt.Printf("[WEBDAV-UPLOAD] BaseURL: %s, BasePrefix: %s, RemotePath: %s, Full URL: %s\n", c.BaseURL, c.basePrefix, remotePath, full)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", full, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size > 0 {
|
||||
req.ContentLength = size
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
} else if resp.StatusCode == 504 {
|
||||
// Treat 504 as success for uploads, as the file may have been uploaded despite the gateway timeout
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Download retrieves a file from the remotePath using HTTP GET (WebDAV).
|
||||
func (c *WebDAVClient) Download(ctx context.Context, remotePath string, rangeHeader string) (*http.Response, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
var full string
|
||||
if u == "" {
|
||||
full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
|
||||
} else {
|
||||
full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
|
||||
}
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", full, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV).
|
||||
func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
var full string
|
||||
if u == "" {
|
||||
full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
|
||||
} else {
|
||||
full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
|
||||
}
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 404 means already deleted, consider it success
|
||||
if resp.StatusCode == 404 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Move moves/renames a file using WebDAV MOVE method
|
||||
func (c *WebDAVClient) Move(ctx context.Context, sourcePath, targetPath string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
// Ensure target parent directory exists before moving
|
||||
if err := c.ensureParent(ctx, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to create target directory: %w", err)
|
||||
}
|
||||
|
||||
sourceRel := strings.TrimLeft(sourcePath, "/")
|
||||
targetRel := strings.TrimLeft(targetPath, "/")
|
||||
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
// Build source URL
|
||||
var sourceURL string
|
||||
if u == "" {
|
||||
sourceURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(sourceRel))
|
||||
} else {
|
||||
sourceURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(sourceRel))
|
||||
}
|
||||
sourceURL = strings.ReplaceAll(sourceURL, "%2F", "/")
|
||||
|
||||
// Build target URL
|
||||
var targetURL string
|
||||
if u == "" {
|
||||
targetURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(targetRel))
|
||||
} else {
|
||||
targetURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(targetRel))
|
||||
}
|
||||
targetURL = strings.ReplaceAll(targetURL, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "MOVE", sourceURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Destination", targetURL)
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav move failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
17
go_cloud/migrations/0003_files.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Create files table for org and user workspaces
|
||||
|
||||
CREATE TABLE files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id UUID REFERENCES organizations(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
size BIGINT DEFAULT 0,
|
||||
last_modified TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_files_org_id ON files(org_id);
|
||||
CREATE INDEX idx_files_user_id ON files(user_id);
|
||||
CREATE INDEX idx_files_path ON files(path);
|
||||
28
go_cloud/migrations/0004_org_owner_slug.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Scope organization slugs per owner instead of globally unique
|
||||
ALTER TABLE organizations ADD COLUMN owner_id UUID REFERENCES users(id);
|
||||
|
||||
WITH first_owner AS (
|
||||
SELECT DISTINCT ON (org_id) org_id, user_id
|
||||
FROM memberships
|
||||
WHERE role = 'owner'
|
||||
ORDER BY org_id, created_at
|
||||
)
|
||||
UPDATE organizations o
|
||||
SET owner_id = fo.user_id
|
||||
FROM first_owner fo
|
||||
WHERE o.id = fo.org_id;
|
||||
|
||||
WITH first_member AS (
|
||||
SELECT DISTINCT ON (org_id) org_id, user_id
|
||||
FROM memberships
|
||||
ORDER BY org_id, created_at
|
||||
)
|
||||
UPDATE organizations o
|
||||
SET owner_id = fm.user_id
|
||||
FROM first_member fm
|
||||
WHERE o.owner_id IS NULL
|
||||
AND o.id = fm.org_id;
|
||||
|
||||
ALTER TABLE organizations ALTER COLUMN owner_id SET NOT NULL;
|
||||
ALTER TABLE organizations DROP CONSTRAINT organizations_slug_key;
|
||||
CREATE UNIQUE INDEX organizations_owner_slug_key ON organizations(owner_id, slug);
|
||||
29
go_cloud/migrations/0005_org_invitations.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Add invitations and join_requests tables for organization management
|
||||
|
||||
CREATE TABLE invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
username TEXT NOT NULL, -- username of the invited user
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() + INTERVAL '7 days'),
|
||||
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE(org_id, username) -- prevent duplicate invites for same user in org
|
||||
);
|
||||
|
||||
CREATE TABLE join_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
invite_token TEXT, -- optional, if from invite link
|
||||
requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
|
||||
UNIQUE(org_id, user_id) -- prevent duplicate requests
|
||||
);
|
||||
|
||||
-- Index for faster lookups
|
||||
CREATE INDEX idx_invitations_org_id ON invitations(org_id);
|
||||
CREATE INDEX idx_invitations_username ON invitations(username);
|
||||
CREATE INDEX idx_join_requests_org_id ON join_requests(org_id);
|
||||
CREATE INDEX idx_join_requests_user_id ON join_requests(user_id);
|
||||
4
go_cloud/migrations/0006_org_invite_link.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add invite_link_token to organizations for shareable invite links
|
||||
|
||||
ALTER TABLE organizations ADD COLUMN invite_link_token TEXT UNIQUE;
|
||||
CREATE INDEX idx_organizations_invite_link_token ON organizations(invite_link_token);
|
||||
17
go_cloud/migrations/0007_file_share_links.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Create file_share_links table
|
||||
|
||||
CREATE TABLE file_share_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
created_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
is_revoked BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
|
||||
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
|
||||
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);
|
||||
10
go_cloud/migrations/0007_file_share_links_down.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Drop file_share_links table
|
||||
|
||||
DROP TABLE IF EXISTS file_share_links;
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
is_revoked BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
|
||||
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
|
||||
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Make org_id nullable in file_share_links for personal file sharing
|
||||
|
||||
ALTER TABLE file_share_links ALTER COLUMN org_id DROP NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Revert: Make org_id not nullable in file_share_links
|
||||
|
||||
ALTER TABLE file_share_links ALTER COLUMN org_id SET NOT NULL;
|
||||