Compare commits
445 Commits
260b8b180e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0837aeabf0 | ||
|
|
4e133b4cb8 | ||
|
|
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 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.tar.gz
|
||||||
|
*_bak.tar.gz
|
||||||
|
EUROSCALE_DEPLOYMENT_BLUEPRINT.md
|
||||||
224
README.md
224
README.md
@@ -1,149 +1,173 @@
|
|||||||
# b0esche.cloud
|
# 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
|
## Project Structure
|
||||||
|
|
||||||
- `go_cloud/`: Go backend (control plane) with REST API
|
```
|
||||||
- `b0esche_cloud/`: Flutter web frontend with BLoC architecture
|
b0esche_cloud/
|
||||||
- Supporting services: Nextcloud (storage), Collabora (editing), PostgreSQL (database)
|
├── 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
|
## Prerequisites
|
||||||
|
|
||||||
- Go 1.21+
|
- Go 1.21+
|
||||||
- Flutter 3.10+
|
- Flutter 3.10+
|
||||||
- Docker and Docker Compose
|
- Docker & Docker Compose
|
||||||
- PostgreSQL (or Docker)
|
- PostgreSQL 15+
|
||||||
- Nextcloud instance
|
|
||||||
- Collabora Online instance
|
|
||||||
|
|
||||||
## Local Development Setup
|
## Local Development
|
||||||
|
|
||||||
### 1. Start Supporting Services
|
### Quick Start
|
||||||
|
|
||||||
Use Docker Compose to start PostgreSQL, Nextcloud, and Collabora:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d db nextcloud collabora
|
# Start everything
|
||||||
|
./scripts/dev-all.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Backend Setup
|
### Manual Setup
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
```bash
|
```bash
|
||||||
cd go_cloud
|
cd go_cloud
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your configuration (DB URL, Nextcloud URL, etc.)
|
# Edit .env with your configuration
|
||||||
go run ./cmd/api
|
go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the provided script:
|
**Frontend:**
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/dev-backend.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Frontend Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd b0esche_cloud
|
cd b0esche_cloud
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d chrome
|
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
|
## 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
|
## Production Deployment
|
||||||
- `JWT_SECRET`: Random secret for JWT signing
|
|
||||||
- `OIDC_*`: OIDC provider settings
|
|
||||||
- `NEXTCLOUD_*`: Nextcloud API settings
|
|
||||||
- `COLLABORA_*`: Collabora settings
|
|
||||||
|
|
||||||
### 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
|
```
|
||||||
|
/opt/
|
||||||
```bash
|
├── traefik/ # Reverse proxy + SSL
|
||||||
cd go_cloud
|
├── go/ # Go backend + PostgreSQL
|
||||||
go test ./...
|
├── flutter/ # Flutter web build + Nginx
|
||||||
|
├── scripts/ # Operations scripts
|
||||||
|
└── auto-deploy/ # Auto-deployment workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Server Scripts
|
||||||
|
|
||||||
```bash
|
| Script | Description |
|
||||||
cd b0esche_cloud
|
|--------|-------------|
|
||||||
flutter test
|
| `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
|
| Component | Technology |
|
||||||
cd go_cloud
|
|-----------|------------|
|
||||||
go build -o bin/api ./cmd/api
|
| 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
|
| Document | Description |
|
||||||
cd b0esche_cloud
|
|----------|-------------|
|
||||||
flutter build web
|
| [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) |
|
||||||
## Database Migrations
|
| [SECURITY.md](docs/SECURITY.md) | Security architecture, hardening, best practices |
|
||||||
|
| [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local setup, coding conventions, testing |
|
||||||
Migrations are in `go_cloud/migrations/`.
|
| [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment, operations, troubleshooting |
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[License here]
|
Private project - All rights reserved
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# b0esche_cloud
|
# b0esche_cloud
|
||||||
|
|
||||||
A new Flutter project.
|
b0esche secure cloud
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'auth_event.dart';
|
|||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
import '../../services/api_client.dart';
|
import '../../services/api_client.dart';
|
||||||
import '../../models/api_error.dart';
|
import '../../models/api_error.dart';
|
||||||
|
import '../../models/user.dart';
|
||||||
|
|
||||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
@@ -22,6 +23,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
|
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
|
||||||
on<LogoutRequested>(_onLogoutRequested);
|
on<LogoutRequested>(_onLogoutRequested);
|
||||||
on<CheckAuthRequested>(_onCheckAuthRequested);
|
on<CheckAuthRequested>(_onCheckAuthRequested);
|
||||||
|
on<UpdateUserProfile>(_onUpdateUserProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onSignupStarted(
|
Future<void> _onSignupStarted(
|
||||||
@@ -54,7 +56,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
add(RegistrationChallengeRequested(userId: userId));
|
add(RegistrationChallengeRequested(userId: userId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +83,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,17 +112,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
sessionBloc.add(SessionStarted(token));
|
sessionBloc.add(SessionStarted(token));
|
||||||
|
|
||||||
emit(
|
// Fetch full profile and include it in state when possible
|
||||||
AuthAuthenticated(
|
try {
|
||||||
token: token,
|
final profile = await apiClient.getUserProfile();
|
||||||
userId: user['id'],
|
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||||
username: user['username'],
|
emit(
|
||||||
email: user['email'],
|
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) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +151,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
add(AuthenticationChallengeRequested(username: event.username));
|
add(AuthenticationChallengeRequested(username: event.username));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +181,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,17 +210,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
sessionBloc.add(SessionStarted(token));
|
sessionBloc.add(SessionStarted(token));
|
||||||
|
|
||||||
emit(
|
// Fetch full profile and include it in state when possible
|
||||||
AuthAuthenticated(
|
try {
|
||||||
token: token,
|
final profile = await apiClient.getUserProfile();
|
||||||
userId: user['id'],
|
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||||
username: user['username'],
|
emit(
|
||||||
email: user['email'],
|
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) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,17 +266,34 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
final user = response['user'];
|
final user = response['user'];
|
||||||
|
|
||||||
sessionBloc.add(SessionStarted(token));
|
sessionBloc.add(SessionStarted(token));
|
||||||
emit(
|
|
||||||
AuthAuthenticated(
|
// Fetch full profile and include it in state when possible
|
||||||
token: token,
|
try {
|
||||||
userId: user['id'],
|
final profile = await apiClient.getUserProfile();
|
||||||
username: user['username'],
|
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||||
email: user['email'],
|
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) {
|
} catch (e) {
|
||||||
final errorMessage = _extractErrorMessage(e);
|
final errorMessage = _extractErrorMessage(e);
|
||||||
emit(AuthFailure(errorMessage));
|
final code = e is ApiError ? e.code : null;
|
||||||
|
emit(AuthFailure(errorMessage, code: code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,18 +312,68 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
final sessionState = sessionBloc.state;
|
final sessionState = sessionBloc.state;
|
||||||
|
|
||||||
if (sessionState is SessionActive) {
|
if (sessionState is SessionActive) {
|
||||||
// Session already active - emit authenticated state with minimal info
|
// Try to fetch full profile immediately so UI can show avatar/displayName
|
||||||
// The full user info will be fetched when needed
|
try {
|
||||||
emit(
|
final profile = await apiClient.getUserProfile();
|
||||||
AuthAuthenticated(
|
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
|
||||||
token: sessionState.token,
|
emit(
|
||||||
userId: '',
|
AuthAuthenticated(
|
||||||
username: '',
|
token: sessionState.token,
|
||||||
email: '',
|
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 {
|
} else {
|
||||||
emit(AuthUnauthenticated());
|
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 'package:equatable/equatable.dart';
|
||||||
|
import '../../models/user.dart';
|
||||||
|
|
||||||
abstract class AuthEvent extends Equatable {
|
abstract class AuthEvent extends Equatable {
|
||||||
const AuthEvent();
|
const AuthEvent();
|
||||||
@@ -135,3 +136,12 @@ class PasswordLoginRequested extends AuthEvent {
|
|||||||
@override
|
@override
|
||||||
List<Object> get props => [username, password];
|
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 'package:equatable/equatable.dart';
|
||||||
|
import '../../models/user.dart';
|
||||||
|
|
||||||
abstract class AuthState extends Equatable {
|
abstract class AuthState extends Equatable {
|
||||||
const AuthState();
|
const AuthState();
|
||||||
@@ -68,25 +69,28 @@ class AuthAuthenticated extends AuthState {
|
|||||||
final String userId;
|
final String userId;
|
||||||
final String username;
|
final String username;
|
||||||
final String email;
|
final String email;
|
||||||
|
final User? user;
|
||||||
|
|
||||||
const AuthAuthenticated({
|
const AuthAuthenticated({
|
||||||
required this.token,
|
required this.token,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.email,
|
required this.email,
|
||||||
|
this.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [token, userId, username, email];
|
List<Object?> get props => [token, userId, username, email, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthFailure extends AuthState {
|
class AuthFailure extends AuthState {
|
||||||
final String error;
|
final String error;
|
||||||
|
final String? code;
|
||||||
|
|
||||||
const AuthFailure(this.error);
|
const AuthFailure(this.error, {this.code});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [error];
|
List<Object?> get props => [error, code];
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthUnauthenticated extends AuthState {
|
class AuthUnauthenticated extends AuthState {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class DocumentViewerBloc
|
|||||||
DocumentViewerReady(
|
DocumentViewerReady(
|
||||||
viewUrl: session.viewUrl,
|
viewUrl: session.viewUrl,
|
||||||
caps: session.capabilities,
|
caps: session.capabilities,
|
||||||
|
token: session.token,
|
||||||
|
fileInfo: session.fileInfo,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_expiryTimer = Timer(
|
_expiryTimer = Timer(
|
||||||
|
|||||||
@@ -15,11 +15,23 @@ class DocumentViewerLoading extends DocumentViewerState {}
|
|||||||
class DocumentViewerReady extends DocumentViewerState {
|
class DocumentViewerReady extends DocumentViewerState {
|
||||||
final Uri viewUrl;
|
final Uri viewUrl;
|
||||||
final DocumentCapabilities caps;
|
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
|
@override
|
||||||
List<Object> get props => [viewUrl, caps];
|
List<Object> get props => [
|
||||||
|
viewUrl,
|
||||||
|
caps,
|
||||||
|
token,
|
||||||
|
if (fileInfo != null) fileInfo!,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
class DocumentViewerError extends DocumentViewerState {
|
class DocumentViewerError extends DocumentViewerState {
|
||||||
|
|||||||
@@ -52,9 +52,13 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!session.readOnly) {
|
if (!session.readOnly) {
|
||||||
emit(EditorSessionActive(editUrl: session.editUrl));
|
emit(
|
||||||
|
EditorSessionActive(editUrl: session.editUrl, token: session.token),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
emit(
|
||||||
|
EditorSessionReadOnly(viewUrl: session.editUrl, token: session.token),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_expiryTimer = Timer(
|
_expiryTimer = Timer(
|
||||||
session.expiresAt.difference(DateTime.now()),
|
session.expiresAt.difference(DateTime.now()),
|
||||||
|
|||||||
@@ -13,20 +13,22 @@ class EditorSessionStarting extends EditorSessionState {}
|
|||||||
|
|
||||||
class EditorSessionActive extends EditorSessionState {
|
class EditorSessionActive extends EditorSessionState {
|
||||||
final Uri editUrl;
|
final Uri editUrl;
|
||||||
|
final String token;
|
||||||
|
|
||||||
const EditorSessionActive({required this.editUrl});
|
const EditorSessionActive({required this.editUrl, required this.token});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [editUrl];
|
List<Object> get props => [editUrl, token];
|
||||||
}
|
}
|
||||||
|
|
||||||
class EditorSessionReadOnly extends EditorSessionState {
|
class EditorSessionReadOnly extends EditorSessionState {
|
||||||
final Uri viewUrl;
|
final Uri viewUrl;
|
||||||
|
final String token;
|
||||||
|
|
||||||
const EditorSessionReadOnly({required this.viewUrl});
|
const EditorSessionReadOnly({required this.viewUrl, required this.token});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [viewUrl];
|
List<Object> get props => [viewUrl, token];
|
||||||
}
|
}
|
||||||
|
|
||||||
class EditorSessionFailed extends EditorSessionState {
|
class EditorSessionFailed extends EditorSessionState {
|
||||||
|
|||||||
@@ -123,24 +123,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
event.parentPath,
|
event.parentPath,
|
||||||
event.folderName,
|
event.folderName,
|
||||||
);
|
);
|
||||||
// Add the new folder to local state if in current directory
|
// Reload directory to get the folder with proper ID from backend
|
||||||
if (event.parentPath == _currentPath) {
|
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||||
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));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(_getErrorMessage(e)));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
@@ -192,7 +176,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
|
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
|
||||||
try {
|
try {
|
||||||
await _fileService.deleteFile(event.orgId, event.path);
|
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
|
_filteredFiles = _currentFiles
|
||||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -206,9 +191,9 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
ResetFileBrowser event,
|
ResetFileBrowser event,
|
||||||
Emitter<FileBrowserState> emit,
|
Emitter<FileBrowserState> emit,
|
||||||
) {
|
) {
|
||||||
emit(DirectoryInitial());
|
|
||||||
_currentOrgId = '';
|
|
||||||
_currentPath = '/';
|
_currentPath = '/';
|
||||||
|
_currentFiles = [];
|
||||||
|
_filteredFiles = [];
|
||||||
_currentFilter = '';
|
_currentFilter = '';
|
||||||
_currentPage = 1;
|
_currentPage = 1;
|
||||||
_pageSize = 20;
|
_pageSize = 20;
|
||||||
@@ -277,6 +262,12 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
) {
|
) {
|
||||||
final sorted = List<FileItem>.from(files);
|
final sorted = List<FileItem>.from(files);
|
||||||
sorted.sort((a, b) {
|
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) {
|
switch (sortBy) {
|
||||||
case 'name':
|
case 'name':
|
||||||
return isAscending
|
return isAscending
|
||||||
@@ -291,12 +282,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
? a.size.compareTo(b.size)
|
? a.size.compareTo(b.size)
|
||||||
: b.size.compareTo(a.size);
|
: b.size.compareTo(a.size);
|
||||||
case 'type':
|
case 'type':
|
||||||
// Folders before files if ascending, else files before folders
|
// Already handled above (folders vs files)
|
||||||
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
|
|
||||||
return isAscending
|
return isAscending
|
||||||
? a.name.compareTo(b.name)
|
? a.name.compareTo(b.name)
|
||||||
: b.name.compareTo(a.name);
|
: b.name.compareTo(a.name);
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
|
|||||||
List<Object> get props => [orgId, parentPath, folderName];
|
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 {
|
class LoadPage extends FileBrowserEvent {
|
||||||
final int page;
|
final int page;
|
||||||
|
|||||||
@@ -73,10 +73,18 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
) {
|
) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is OrganizationLoaded) {
|
if (currentState is OrganizationLoaded) {
|
||||||
final selected = currentState.organizations.firstWhere(
|
Organization? selected;
|
||||||
(org) => org.id == event.orgId,
|
|
||||||
orElse: () => currentState.selectedOrg!,
|
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(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: currentState.organizations,
|
||||||
@@ -86,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
);
|
);
|
||||||
// Reset all dependent blocs
|
// Reset all dependent blocs
|
||||||
permissionBloc.add(PermissionsReset());
|
permissionBloc.add(PermissionsReset());
|
||||||
fileBrowserBloc.add(ResetFileBrowser());
|
fileBrowserBloc.add(ResetFileBrowser(event.orgId));
|
||||||
uploadBloc.add(ResetUploads());
|
uploadBloc.add(ResetUploads());
|
||||||
// Load permissions for the selected org
|
// Load permissions for the selected org
|
||||||
permissionBloc.add(LoadPermissions(event.orgId));
|
permissionBloc.add(LoadPermissions(event.orgId));
|
||||||
@@ -97,59 +105,72 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
CreateOrganization event,
|
CreateOrganization event,
|
||||||
Emitter<OrganizationState> emit,
|
Emitter<OrganizationState> emit,
|
||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
final name = event.name.trim();
|
||||||
if (currentState is OrganizationLoaded) {
|
if (name.isEmpty) {
|
||||||
final name = event.name.trim();
|
// Try to preserve current state if possible
|
||||||
if (name.isEmpty) {
|
if (state is OrganizationLoaded) {
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: (state as OrganizationLoaded).organizations,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: (state as OrganizationLoaded).selectedOrg,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'Organization name cannot be empty',
|
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(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: existingOrgs,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: selectedOrg,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'Organization with this name already exists',
|
error: 'Organization with this name already exists',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
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(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: existingOrgs,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: selectedOrg,
|
||||||
isLoading: true,
|
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:bloc/bloc.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'permission_event.dart';
|
import 'permission_event.dart';
|
||||||
import 'permission_state.dart';
|
import 'permission_state.dart';
|
||||||
|
import '../../services/api_client.dart';
|
||||||
|
|
||||||
class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
||||||
PermissionBloc() : super(PermissionInitial()) {
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
PermissionBloc(this.apiClient) : super(PermissionInitial()) {
|
||||||
on<LoadPermissions>(_onLoadPermissions);
|
on<LoadPermissions>(_onLoadPermissions);
|
||||||
on<PermissionsReset>(_onPermissionsReset);
|
on<PermissionsReset>(_onPermissionsReset);
|
||||||
}
|
}
|
||||||
@@ -12,20 +16,36 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
|||||||
LoadPermissions event,
|
LoadPermissions event,
|
||||||
Emitter<PermissionState> emit,
|
Emitter<PermissionState> emit,
|
||||||
) async {
|
) 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());
|
emit(PermissionLoading());
|
||||||
// Simulate loading permissions from backend for orgId
|
try {
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
final response = await apiClient.getRaw(
|
||||||
// Mock capabilities based on orgId
|
'/orgs/${event.orgId}/permissions',
|
||||||
// Allow all permissions for authenticated users (proper permissions should come from backend)
|
);
|
||||||
final capabilities = Capabilities(
|
final capabilities = Capabilities(
|
||||||
canRead: true,
|
canRead: response['canRead'] ?? false,
|
||||||
canWrite: true,
|
canWrite: response['canWrite'] ?? false,
|
||||||
canShare: true,
|
canShare: response['canShare'] ?? false,
|
||||||
canAdmin: true,
|
canAdmin: response['canAdmin'] ?? false,
|
||||||
canAnnotate: true,
|
canAnnotate: response['canAnnotate'] ?? false,
|
||||||
canEdit: true,
|
canEdit: response['canEdit'] ?? false,
|
||||||
);
|
);
|
||||||
emit(PermissionLoaded(capabilities));
|
emit(PermissionLoaded(capabilities));
|
||||||
|
} catch (e) {
|
||||||
|
emit(PermissionDenied(e.toString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPermissionsReset(
|
void _onPermissionsReset(
|
||||||
|
|||||||
@@ -24,18 +24,11 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
|
|||||||
|
|
||||||
for (final file in event.files) {
|
for (final file in event.files) {
|
||||||
try {
|
try {
|
||||||
print(
|
|
||||||
'[UploadBloc] Starting upload for ${file.name} to orgId=${event.orgId}, path=${file.path}',
|
|
||||||
);
|
|
||||||
print(
|
|
||||||
'[UploadBloc] File bytes: ${file.bytes?.length ?? 0} bytes, localPath: ${file.localPath}',
|
|
||||||
);
|
|
||||||
// Simulate upload
|
// Simulate upload
|
||||||
await _fileRepository.uploadFile(event.orgId, file);
|
await _fileRepository.uploadFile(event.orgId, file);
|
||||||
print('[UploadBloc] Upload successful for ${file.name}');
|
|
||||||
add(UploadCompleted(file));
|
add(UploadCompleted(file));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[UploadBloc] Upload failed for ${file.name}: $e');
|
|
||||||
add(UploadFailed(fileName: file.name, error: e.toString()));
|
add(UploadFailed(fileName: file.name, error: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:b0esche_cloud/services/api_client.dart';
|
import 'services/api_client.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'blocs/session/session_bloc.dart';
|
import 'blocs/session/session_bloc.dart';
|
||||||
import 'repositories/auth_repository.dart';
|
import 'repositories/auth_repository.dart';
|
||||||
@@ -8,8 +8,6 @@ import 'repositories/http_file_repository.dart';
|
|||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
import 'services/file_service.dart';
|
import 'services/file_service.dart';
|
||||||
import 'services/org_api.dart';
|
import 'services/org_api.dart';
|
||||||
import 'viewmodels/login_view_model.dart';
|
|
||||||
import 'viewmodels/file_explorer_view_model.dart';
|
|
||||||
|
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
@@ -28,10 +26,4 @@ void configureDependencies(SessionBloc sessionBloc) {
|
|||||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||||
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||||
|
|
||||||
// Register viewmodels
|
|
||||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
|
||||||
getIt.registerSingleton<FileExplorerViewModel>(
|
|
||||||
FileExplorerViewModel(getIt<FileService>()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'blocs/auth/auth_bloc.dart';
|
import 'blocs/auth/auth_bloc.dart';
|
||||||
|
import 'blocs/auth/auth_event.dart';
|
||||||
import 'blocs/session/session_bloc.dart';
|
import 'blocs/session/session_bloc.dart';
|
||||||
import 'blocs/activity/activity_bloc.dart';
|
import 'blocs/activity/activity_bloc.dart';
|
||||||
import 'services/api_client.dart';
|
import 'services/api_client.dart';
|
||||||
@@ -10,12 +11,20 @@ import 'pages/home_page.dart';
|
|||||||
import 'pages/file_explorer.dart';
|
import 'pages/file_explorer.dart';
|
||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
import 'pages/editor_page.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 'theme/app_theme.dart';
|
||||||
import 'injection.dart';
|
import 'injection.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
final GoRouter _router = GoRouter(
|
final GoRouter _router = GoRouter(
|
||||||
|
initialLocation: kIsWeb ? Uri.base.path : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => const HomePage()),
|
GoRoute(path: '/', builder: (context, state) => const HomePage()),
|
||||||
|
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/viewer/:orgId/:fileId',
|
path: '/viewer/:orgId/:fileId',
|
||||||
builder: (context, state) => DocumentViewer(
|
builder: (context, state) => DocumentViewer(
|
||||||
@@ -35,6 +44,16 @@ final GoRouter _router = GoRouter(
|
|||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
FileExplorer(orgId: state.pathParameters['orgId']!),
|
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']!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -52,19 +71,27 @@ class MainApp extends StatefulWidget {
|
|||||||
class _MainAppState extends State<MainApp> {
|
class _MainAppState extends State<MainApp> {
|
||||||
final _sessionBloc = SessionBloc();
|
final _sessionBloc = SessionBloc();
|
||||||
late final AuthBloc _authBloc;
|
late final AuthBloc _authBloc;
|
||||||
|
late final Future<void> _restoreFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Restore session from persistent storage early so ApiClient has token if present
|
// Configure DI first
|
||||||
SessionBloc.restoreSession(_sessionBloc);
|
|
||||||
// Configure DI to use HTTP repositories
|
|
||||||
configureDependencies(_sessionBloc);
|
configureDependencies(_sessionBloc);
|
||||||
|
|
||||||
|
// Create AuthBloc first
|
||||||
_authBloc = AuthBloc(
|
_authBloc = AuthBloc(
|
||||||
apiClient: ApiClient(_sessionBloc),
|
apiClient: ApiClient(_sessionBloc),
|
||||||
sessionBloc: _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
|
@override
|
||||||
@@ -78,9 +105,41 @@ class _MainAppState extends State<MainApp> {
|
|||||||
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: FutureBuilder<void>(
|
||||||
routerConfig: _router,
|
future: _restoreFuture,
|
||||||
theme: AppTheme.darkTheme,
|
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!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,64 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
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 {
|
class DocumentCapabilities extends Equatable {
|
||||||
final bool canEdit;
|
final bool canEdit;
|
||||||
final bool canAnnotate;
|
final bool canAnnotate;
|
||||||
final bool isPdf;
|
final bool isPdf;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
const DocumentCapabilities({
|
const DocumentCapabilities({
|
||||||
required this.canEdit,
|
required this.canEdit,
|
||||||
required this.canAnnotate,
|
required this.canAnnotate,
|
||||||
required this.isPdf,
|
required this.isPdf,
|
||||||
|
required this.mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [canEdit, canAnnotate, isPdf];
|
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
|
||||||
|
|
||||||
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
||||||
return DocumentCapabilities(
|
return DocumentCapabilities(
|
||||||
canEdit: json['canEdit'],
|
canEdit: json['canEdit'],
|
||||||
canAnnotate: json['canAnnotate'],
|
canAnnotate: json['canAnnotate'],
|
||||||
isPdf: json['isPdf'],
|
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 {
|
class EditorSession extends Equatable {
|
||||||
final Uri editUrl;
|
final Uri editUrl;
|
||||||
|
final String token;
|
||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final DateTime expiresAt;
|
final DateTime expiresAt;
|
||||||
|
|
||||||
const EditorSession({
|
const EditorSession({
|
||||||
required this.editUrl,
|
required this.editUrl,
|
||||||
|
required this.token,
|
||||||
required this.readOnly,
|
required this.readOnly,
|
||||||
required this.expiresAt,
|
required this.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [editUrl, readOnly, expiresAt];
|
List<Object?> get props => [editUrl, token, readOnly, expiresAt];
|
||||||
|
|
||||||
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
||||||
return EditorSession(
|
return EditorSession(
|
||||||
editUrl: Uri.parse(json['editUrl']),
|
editUrl: Uri.parse(json['editUrl']),
|
||||||
|
token: json['token'] ?? '',
|
||||||
readOnly: json['readOnly'],
|
readOnly: json['readOnly'],
|
||||||
expiresAt: DateTime.parse(json['expiresAt']),
|
expiresAt: DateTime.parse(json['expiresAt']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
enum FileType { folder, file }
|
enum FileType { folder, file }
|
||||||
|
|
||||||
class FileItem extends Equatable {
|
class FileItem extends Equatable {
|
||||||
|
final String? id;
|
||||||
final String name;
|
final String name;
|
||||||
final String path;
|
final String path;
|
||||||
final FileType type;
|
final FileType type;
|
||||||
@@ -13,6 +14,7 @@ class FileItem extends Equatable {
|
|||||||
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||||
|
|
||||||
const FileItem({
|
const FileItem({
|
||||||
|
this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -23,9 +25,10 @@ class FileItem extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [name, path, type, size, lastModified];
|
List<Object?> get props => [id, name, path, type, size, lastModified];
|
||||||
|
|
||||||
FileItem copyWith({
|
FileItem copyWith({
|
||||||
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
String? path,
|
String? path,
|
||||||
FileType? type,
|
FileType? type,
|
||||||
@@ -33,6 +36,7 @@ class FileItem extends Equatable {
|
|||||||
DateTime? lastModified,
|
DateTime? lastModified,
|
||||||
}) {
|
}) {
|
||||||
return FileItem(
|
return FileItem(
|
||||||
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
|||||||
96
b0esche_cloud/lib/models/organization.dart
Normal file
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 username;
|
||||||
final String email;
|
final String email;
|
||||||
final String? displayName;
|
final String? displayName;
|
||||||
|
final String? avatarUrl;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime? lastLoginAt;
|
final DateTime? lastLoginAt;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class User extends Equatable {
|
|||||||
required this.username,
|
required this.username,
|
||||||
required this.email,
|
required this.email,
|
||||||
this.displayName,
|
this.displayName,
|
||||||
|
this.avatarUrl,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.lastLoginAt,
|
this.lastLoginAt,
|
||||||
});
|
});
|
||||||
@@ -23,6 +25,7 @@ class User extends Equatable {
|
|||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
createdAt,
|
createdAt,
|
||||||
lastLoginAt,
|
lastLoginAt,
|
||||||
];
|
];
|
||||||
@@ -32,6 +35,7 @@ class User extends Equatable {
|
|||||||
String? username,
|
String? username,
|
||||||
String? email,
|
String? email,
|
||||||
String? displayName,
|
String? displayName,
|
||||||
|
String? avatarUrl,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? lastLoginAt,
|
DateTime? lastLoginAt,
|
||||||
}) {
|
}) {
|
||||||
@@ -40,6 +44,7 @@ class User extends Equatable {
|
|||||||
username: username ?? this.username,
|
username: username ?? this.username,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
displayName: displayName ?? this.displayName,
|
displayName: displayName ?? this.displayName,
|
||||||
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||||
);
|
);
|
||||||
@@ -51,6 +56,7 @@ class User extends Equatable {
|
|||||||
username: json['username'] as String,
|
username: json['username'] as String,
|
||||||
email: json['email'] as String,
|
email: json['email'] as String,
|
||||||
displayName: json['displayName'] as String?,
|
displayName: json['displayName'] as String?,
|
||||||
|
avatarUrl: json['avatarUrl'] as String?,
|
||||||
createdAt: DateTime.parse(
|
createdAt: DateTime.parse(
|
||||||
json['createdAt'] as String? ?? DateTime.now().toIso8601String(),
|
json['createdAt'] as String? ?? DateTime.now().toIso8601String(),
|
||||||
),
|
),
|
||||||
@@ -66,6 +72,7 @@ class User extends Equatable {
|
|||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'email': email,
|
||||||
'displayName': displayName,
|
'displayName': displayName,
|
||||||
|
'avatarUrl': avatarUrl,
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'lastLoginAt': lastLoginAt?.toIso8601String(),
|
'lastLoginAt': lastLoginAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,16 +6,24 @@ class ViewerSession extends Equatable {
|
|||||||
final DocumentCapabilities capabilities;
|
final DocumentCapabilities capabilities;
|
||||||
final String token;
|
final String token;
|
||||||
final DateTime expiresAt;
|
final DateTime expiresAt;
|
||||||
|
final FileInfo? fileInfo;
|
||||||
|
|
||||||
const ViewerSession({
|
const ViewerSession({
|
||||||
required this.viewUrl,
|
required this.viewUrl,
|
||||||
required this.capabilities,
|
required this.capabilities,
|
||||||
required this.token,
|
required this.token,
|
||||||
required this.expiresAt,
|
required this.expiresAt,
|
||||||
|
this.fileInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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) {
|
factory ViewerSession.fromJson(Map<String, dynamic> json) {
|
||||||
return ViewerSession(
|
return ViewerSession(
|
||||||
@@ -23,6 +31,9 @@ class ViewerSession extends Equatable {
|
|||||||
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
|
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
|
||||||
token: json['token'],
|
token: json['token'],
|
||||||
expiresAt: DateTime.parse(json['expiresAt']),
|
expiresAt: DateTime.parse(json['expiresAt']),
|
||||||
|
fileInfo: json['fileInfo'] != null
|
||||||
|
? FileInfo.fromJson(json['fileInfo'])
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'dart:ui_web' as ui;
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_bloc.dart';
|
import '../blocs/document_viewer/document_viewer_bloc.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_event.dart';
|
import '../blocs/document_viewer/document_viewer_event.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_state.dart';
|
import '../blocs/document_viewer/document_viewer_state.dart';
|
||||||
|
import '../blocs/session/session_bloc.dart';
|
||||||
|
import '../blocs/session/session_state.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
import '../injection.dart';
|
import '../injection.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
import 'package:syncfusion_flutter_core/theme.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../widgets/file_viewer_dispatch.dart';
|
||||||
|
|
||||||
// Modal version for overlay display
|
// Modal version for overlay display
|
||||||
class DocumentViewerModal extends StatefulWidget {
|
class DocumentViewerModal extends StatefulWidget {
|
||||||
@@ -106,6 +115,22 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|||||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DocumentViewerReady) {
|
if (state is DocumentViewerReady) {
|
||||||
|
final fileInfo = state.fileInfo;
|
||||||
|
String lastModifiedText = 'Last modified: Unknown';
|
||||||
|
if (fileInfo != null) {
|
||||||
|
final modifiedDate = fileInfo.lastModified;
|
||||||
|
final modifiedBy = fileInfo.modifiedByName;
|
||||||
|
if (modifiedDate != null) {
|
||||||
|
final formattedDate =
|
||||||
|
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
|
||||||
|
if (modifiedBy != null && modifiedBy.isNotEmpty) {
|
||||||
|
lastModifiedText =
|
||||||
|
'Last modified: $formattedDate by $modifiedBy';
|
||||||
|
} else {
|
||||||
|
lastModifiedText = 'Last modified: $formattedDate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
height: 30,
|
height: 30,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -113,9 +138,9 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Last modified: Unknown by Unknown (v1)',
|
lastModifiedText,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppTheme.secondaryText,
|
color: AppTheme.secondaryText,
|
||||||
),
|
),
|
||||||
@@ -130,7 +155,16 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|||||||
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DocumentViewerLoading) {
|
if (state is DocumentViewerLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state is DocumentViewerError) {
|
if (state is DocumentViewerError) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -166,16 +200,125 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is DocumentViewerReady) {
|
if (state is DocumentViewerReady) {
|
||||||
|
// Handle different file types based on MIME type
|
||||||
if (state.caps.isPdf) {
|
if (state.caps.isPdf) {
|
||||||
return SfPdfViewer.network(state.viewUrl.toString());
|
return FileViewerDispatch.buildFileViewer(
|
||||||
|
context,
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.caps.mimeType,
|
||||||
|
token: state.token,
|
||||||
|
fileName: state.fileInfo?.name,
|
||||||
|
viewerId: 'internal-pdf-${state.viewUrl.hashCode}',
|
||||||
|
onHyperlinkClicked: (details) =>
|
||||||
|
_handleHyperlink(details.uri),
|
||||||
|
);
|
||||||
|
} else if (state.caps.isImage) {
|
||||||
|
return FileViewerDispatch.buildFileViewer(
|
||||||
|
context,
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.caps.mimeType,
|
||||||
|
token: state.token,
|
||||||
|
fileName: state.fileInfo?.name,
|
||||||
|
viewerId: 'internal-image-${state.viewUrl.hashCode}',
|
||||||
|
);
|
||||||
|
} else if (state.caps.isVideo) {
|
||||||
|
return FileViewerDispatch.buildFileViewer(
|
||||||
|
context,
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.caps.mimeType,
|
||||||
|
token: state.token,
|
||||||
|
fileName: state.fileInfo?.name,
|
||||||
|
viewerId: 'internal-video-${state.viewUrl.hashCode}',
|
||||||
|
);
|
||||||
|
} else if (state.caps.isAudio) {
|
||||||
|
return FileViewerDispatch.buildFileViewer(
|
||||||
|
context,
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.caps.mimeType,
|
||||||
|
token: state.token,
|
||||||
|
fileName: state.fileInfo?.name,
|
||||||
|
viewerId: 'internal-audio-${state.viewUrl.hashCode}',
|
||||||
|
);
|
||||||
|
} else if (state.caps.isText) {
|
||||||
|
// Text file viewer
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _fetchTextContent(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.token,
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error loading text: ${snapshot.error}',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SelectableText(
|
||||||
|
snapshot.data ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontFamily: 'Courier New',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (state.caps.isOffice) {
|
||||||
|
// Office document viewer using Collabora Online
|
||||||
|
return _buildCollaboraViewer(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.token,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Unknown file type
|
||||||
return Container(
|
return Container(
|
||||||
color: AppTheme.secondaryText,
|
color: AppTheme.primaryBackground,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Column(
|
||||||
'Office Document Viewer\\n(URL: ${state.viewUrl})',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
Icon(
|
||||||
|
Icons.description,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'File Type Not Supported',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'MIME Type: ${state.caps.mimeType}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -195,6 +338,293 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> _fetchTextContent(String url, String token) async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return response.body;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load text: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error fetching text: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCollaboraViewer(String documentUrl, String token) {
|
||||||
|
// Create WOPI session to get WOPISrc URL
|
||||||
|
return FutureBuilder<WOPISession>(
|
||||||
|
future: _createWOPISession(token),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Loading document in Collabora Online...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 64, color: Colors.red[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Failed to load document',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${snapshot.error}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'No session data',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: unused_local_variable
|
||||||
|
final wopiSession = snapshot.data!;
|
||||||
|
|
||||||
|
// Use backend proxy endpoint to serve the Collabora form
|
||||||
|
final proxyUrl = _buildProxyUrl(token);
|
||||||
|
return _buildWebView(proxyUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildProxyUrl(String token) {
|
||||||
|
// Build the proxy URL based on whether we're in org or user workspace
|
||||||
|
String baseUrl = 'https://go.b0esche.cloud';
|
||||||
|
String endpoint;
|
||||||
|
|
||||||
|
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
||||||
|
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/collabora-proxy';
|
||||||
|
} else {
|
||||||
|
endpoint = '/user/files/${widget.fileId}/collabora-proxy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass token as query parameter for iframe (which cannot send Authorization header)
|
||||||
|
return '$baseUrl$endpoint?token=$token';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WOPISession> _createWOPISession(String token) async {
|
||||||
|
try {
|
||||||
|
// Use default base URL from backend
|
||||||
|
String baseUrl = 'https://go.b0esche.cloud';
|
||||||
|
|
||||||
|
// Determine endpoint based on whether we're in org or user workspace
|
||||||
|
String endpoint;
|
||||||
|
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
||||||
|
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session';
|
||||||
|
} else {
|
||||||
|
endpoint = '/user/files/${widget.fileId}/wopi-session';
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
Uri.parse('$baseUrl$endpoint'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return WOPISession(
|
||||||
|
wopisrc: json['wopi_src'] as String,
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to create WOPI session: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error creating WOPI session: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCollaboraIframe(String proxyUrl) {
|
||||||
|
// Load the backend proxy page which handles Collabora form submission
|
||||||
|
final String viewType =
|
||||||
|
'collabora-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
ui.platformViewRegistry.registerViewFactory(viewType, (int viewId) {
|
||||||
|
// Create iframe pointing to the proxy endpoint
|
||||||
|
final iframe = web.HTMLIFrameElement()
|
||||||
|
..style.border = 'none'
|
||||||
|
..style.width = '100%'
|
||||||
|
..style.height = '100%'
|
||||||
|
..style.margin = '0'
|
||||||
|
..style.padding = '0'
|
||||||
|
..src = proxyUrl
|
||||||
|
..setAttribute(
|
||||||
|
'allow',
|
||||||
|
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write; fullscreen',
|
||||||
|
)
|
||||||
|
..setAttribute(
|
||||||
|
'sandbox',
|
||||||
|
'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation',
|
||||||
|
);
|
||||||
|
|
||||||
|
final container = web.HTMLDivElement()
|
||||||
|
..style.width = '100%'
|
||||||
|
..style.height = '100%'
|
||||||
|
..style.margin = '0'
|
||||||
|
..style.padding = '0'
|
||||||
|
..style.overflow = 'hidden'
|
||||||
|
..append(iframe);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
});
|
||||||
|
|
||||||
|
return HtmlElementView(viewType: viewType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWebView(String proxyUrl) {
|
||||||
|
// Embed Collabora Online via proxy endpoint
|
||||||
|
return _buildCollaboraIframe(proxyUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleHyperlink(String url) async {
|
||||||
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: Container(
|
||||||
|
decoration: AppTheme.glassDecoration,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Open Link',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Open this link in your browser?\n\n$url',
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text(
|
||||||
|
'Cancel',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text(
|
||||||
|
'Open',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppTheme.accentColor,
|
||||||
|
decorationThickness: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldOpen == true) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_viewerBloc.close();
|
_viewerBloc.close();
|
||||||
@@ -202,6 +632,21 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WOPI Session model for Collabora Online integration
|
||||||
|
class WOPISession {
|
||||||
|
final String wopisrc;
|
||||||
|
final String accessToken;
|
||||||
|
|
||||||
|
WOPISession({required this.wopisrc, required this.accessToken});
|
||||||
|
|
||||||
|
factory WOPISession.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WOPISession(
|
||||||
|
wopisrc: json['wopi_src'] as String,
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Original page version (for routing if needed)
|
// Original page version (for routing if needed)
|
||||||
class DocumentViewer extends StatefulWidget {
|
class DocumentViewer extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
@@ -235,13 +680,28 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DocumentViewerReady) {
|
if (state is DocumentViewerReady) {
|
||||||
// Placeholder for meta
|
final fileInfo = state.fileInfo;
|
||||||
|
String lastModifiedText = 'Last modified: Unknown';
|
||||||
|
if (fileInfo != null) {
|
||||||
|
final modifiedDate = fileInfo.lastModified;
|
||||||
|
final modifiedBy = fileInfo.modifiedByName;
|
||||||
|
if (modifiedDate != null) {
|
||||||
|
final formattedDate =
|
||||||
|
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
|
||||||
|
if (modifiedBy != null && modifiedBy.isNotEmpty) {
|
||||||
|
lastModifiedText =
|
||||||
|
'Last modified: $formattedDate by $modifiedBy';
|
||||||
|
} else {
|
||||||
|
lastModifiedText = 'Last modified: $formattedDate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
height: 30,
|
height: 30,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Last modified: Unknown by Unknown (v1)',
|
lastModifiedText,
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -288,7 +748,16 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DocumentViewerLoading) {
|
if (state is DocumentViewerLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state is DocumentViewerError) {
|
if (state is DocumentViewerError) {
|
||||||
return Center(child: Text('Error: ${state.message}'));
|
return Center(child: Text('Error: ${state.message}'));
|
||||||
@@ -317,22 +786,151 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is DocumentViewerReady) {
|
if (state is DocumentViewerReady) {
|
||||||
if (state.caps.isPdf) {
|
return BlocBuilder<SessionBloc, SessionState>(
|
||||||
// Use PDF viewer
|
builder: (context, sessionState) {
|
||||||
return SfPdfViewer.network(state.viewUrl.toString());
|
String? token;
|
||||||
} else {
|
if (sessionState is SessionActive) {
|
||||||
// Placeholder for office docs iframe
|
token = sessionState.token;
|
||||||
return Container(
|
}
|
||||||
color: AppTheme.secondaryText,
|
|
||||||
child: Center(
|
if (state.caps.isPdf) {
|
||||||
child: Text(
|
// PDF viewer using SfPdfViewer, wrapped in SfTheme for custom accent color
|
||||||
'Office Document Viewer\n(URL: ${state.viewUrl})',
|
return SfTheme(
|
||||||
textAlign: TextAlign.center,
|
data: SfThemeData(
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
pdfViewerThemeData: SfPdfViewerThemeData(
|
||||||
),
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
),
|
progressBarColor: AppTheme.accentColor,
|
||||||
);
|
scrollStatusStyle: PdfScrollStatusStyle(
|
||||||
}
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
|
),
|
||||||
|
scrollHeadStyle: PdfScrollHeadStyle(
|
||||||
|
backgroundColor: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SfPdfViewer.network(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
headers: token != null
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: {},
|
||||||
|
onDocumentLoadFailed: (details) {},
|
||||||
|
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
||||||
|
canShowHyperlinkDialog: false,
|
||||||
|
enableHyperlinkNavigation: false,
|
||||||
|
onHyperlinkClicked: (details) =>
|
||||||
|
_handleHyperlink(details.uri),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (state.caps.isImage) {
|
||||||
|
// Image viewer
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 4.0,
|
||||||
|
child: Image.network(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
headers: token != null
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: {},
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load image',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (state.caps.isText) {
|
||||||
|
// Text file viewer
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _fetchTextContent(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
token ?? '',
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error loading text: ${snapshot.error}',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SelectableText(
|
||||||
|
snapshot.data ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontFamily: 'Courier New',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (state.caps.isOffice) {
|
||||||
|
// Office document viewer using Collabora Online
|
||||||
|
return _buildCollaboraViewerPage(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
token ?? '',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Unknown file type
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.description,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'File Type Not Supported',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'MIME Type: ${state.caps.mimeType}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return const Center(child: Text('No document loaded'));
|
return const Center(child: Text('No document loaded'));
|
||||||
},
|
},
|
||||||
@@ -341,6 +939,164 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> _fetchTextContent(String url, String token) async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return response.body;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load text: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error fetching text: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCollaboraViewerPage(String documentUrl, String token) {
|
||||||
|
// Build HTML to embed Collabora Online viewer
|
||||||
|
// For now, we'll show the document download option with a link to open in Collabora
|
||||||
|
// A proper implementation would require WOPI protocol support
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.description, size: 64, color: AppTheme.accentColor),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Office Document',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Collabora Online Viewer',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Opening document in Collabora...',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Download File'),
|
||||||
|
onPressed: () {
|
||||||
|
// Open file download
|
||||||
|
// In a real implementation, you'd use url_launcher
|
||||||
|
// launchUrl(state.viewUrl);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleHyperlink(String url) async {
|
||||||
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: Container(
|
||||||
|
decoration: AppTheme.glassDecoration,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Open Link',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Do you want to open this link in your browser?\n\n$url',
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text(
|
||||||
|
'Cancel',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text(
|
||||||
|
'Open',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppTheme.accentColor,
|
||||||
|
decorationThickness: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldOpen == true) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_viewerBloc.close();
|
_viewerBloc.close();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:b0esche_cloud/blocs/organization/organization_state.dart';
|
import '../blocs/organization/organization_state.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../blocs/auth/auth_bloc.dart';
|
import '../blocs/auth/auth_bloc.dart';
|
||||||
@@ -9,14 +9,20 @@ import '../blocs/organization/organization_event.dart';
|
|||||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||||
import '../blocs/file_browser/file_browser_event.dart';
|
import '../blocs/file_browser/file_browser_event.dart';
|
||||||
import '../blocs/permission/permission_bloc.dart';
|
import '../blocs/permission/permission_bloc.dart';
|
||||||
|
import '../blocs/permission/permission_event.dart';
|
||||||
import '../blocs/upload/upload_bloc.dart';
|
import '../blocs/upload/upload_bloc.dart';
|
||||||
import '../repositories/file_repository.dart';
|
import '../repositories/file_repository.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
import '../services/org_api.dart';
|
import '../services/org_api.dart';
|
||||||
|
import '../services/api_client.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../theme/modern_glass_button.dart';
|
import '../theme/modern_glass_button.dart';
|
||||||
import 'login_form.dart' show LoginForm;
|
import 'login_form.dart' show LoginForm;
|
||||||
import 'file_explorer.dart';
|
import 'file_explorer.dart';
|
||||||
|
import '../widgets/organization_settings_dialog.dart';
|
||||||
|
import '../widgets/account_settings_dialog.dart';
|
||||||
|
import '../widgets/add_features_dialog.dart';
|
||||||
|
import '../widgets/audio_player_bar.dart';
|
||||||
import '../injection.dart';
|
import '../injection.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
@@ -27,6 +33,12 @@ class HomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||||
|
// Audio bar state
|
||||||
|
String? _audioFileName;
|
||||||
|
String? _audioFileUrl;
|
||||||
|
bool _showAudioBar = false;
|
||||||
|
late AnimationController _audioBarController;
|
||||||
|
late Animation<Offset> _audioBarOffset;
|
||||||
late String _selectedTab = 'Drive';
|
late String _selectedTab = 'Drive';
|
||||||
late AnimationController _animationController;
|
late AnimationController _animationController;
|
||||||
bool _isSignupMode = false;
|
bool _isSignupMode = false;
|
||||||
@@ -46,7 +58,19 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
_permissionBloc = PermissionBloc();
|
_audioBarController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_audioBarOffset =
|
||||||
|
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _audioBarController,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_permissionBloc = PermissionBloc(getIt<ApiClient>());
|
||||||
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
||||||
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
||||||
_organizationBloc = OrganizationBloc(
|
_organizationBloc = OrganizationBloc(
|
||||||
@@ -60,6 +84,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_audioBarController.dispose();
|
||||||
_organizationBloc.close();
|
_organizationBloc.close();
|
||||||
_uploadBloc.close();
|
_uploadBloc.close();
|
||||||
_fileBrowserBloc.close();
|
_fileBrowserBloc.close();
|
||||||
@@ -67,6 +92,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onAudioFileSelected(String fileName, String fileUrl) {
|
||||||
|
setState(() {
|
||||||
|
_audioFileName = fileName;
|
||||||
|
_audioFileUrl = fileUrl;
|
||||||
|
_showAudioBar = true;
|
||||||
|
});
|
||||||
|
_audioBarController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
void _setSignupMode(bool isSignup) {
|
void _setSignupMode(bool isSignup) {
|
||||||
if (_isSignupMode && !isSignup) {
|
if (_isSignupMode && !isSignup) {
|
||||||
Future.delayed(const Duration(milliseconds: 200), () {
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
@@ -85,7 +119,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Dialog(
|
builder: (dialogContext) => Dialog(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 400,
|
width: 400,
|
||||||
@@ -111,6 +145,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
TextField(
|
TextField(
|
||||||
cursorColor: AppTheme.accentColor,
|
cursorColor: AppTheme.accentColor,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
style: TextStyle(color: AppTheme.primaryText),
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Organization Name',
|
labelText: 'Organization Name',
|
||||||
@@ -126,24 +161,38 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
borderSide: BorderSide(color: AppTheme.accentColor),
|
borderSide: BorderSide(color: AppTheme.accentColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
final name = controller.text.trim();
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
// Use the parent context, not the dialog context
|
||||||
|
BlocProvider.of<OrganizationBloc>(
|
||||||
|
context,
|
||||||
|
).add(CreateOrganization(name));
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final name = controller.text.trim();
|
final name = controller.text.trim();
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
context.read<OrganizationBloc>().add(
|
// Use the parent context, not the dialog context
|
||||||
CreateOrganization(name),
|
BlocProvider.of<OrganizationBloc>(
|
||||||
);
|
context,
|
||||||
Navigator.of(context).pop();
|
).add(CreateOrganization(name));
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Create'),
|
child: const Text('Create'),
|
||||||
@@ -160,6 +209,36 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showOrganizationSettings(BuildContext context) {
|
||||||
|
final orgState = _organizationBloc.state;
|
||||||
|
final permState = _permissionBloc.state;
|
||||||
|
|
||||||
|
if (orgState is OrganizationLoaded && orgState.selectedOrg != null) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => OrganizationSettingsDialog(
|
||||||
|
organization: orgState.selectedOrg!,
|
||||||
|
permissionState: permState,
|
||||||
|
orgApi: getIt<OrgApi>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAccountSettings(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => const AccountSettingsDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddFeaturesDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => const AddFeaturesDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildOrgRow(BuildContext context) {
|
Widget _buildOrgRow(BuildContext context) {
|
||||||
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -179,6 +258,12 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Personal workspace button (always show when logged in)
|
||||||
|
_buildPersonalButton(selectedOrg == null, () {
|
||||||
|
context.read<OrganizationBloc>().add(SelectOrganization(''));
|
||||||
|
}),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Organization tabs
|
||||||
...orgs.map(
|
...orgs.map(
|
||||||
(org) => Row(
|
(org) => Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -217,6 +302,17 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||||
final defaultColor = AppTheme.secondaryText;
|
final defaultColor = AppTheme.secondaryText;
|
||||||
return TextButton(
|
return TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
child: Text(
|
child: Text(
|
||||||
org.name,
|
org.name,
|
||||||
@@ -228,9 +324,46 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPersonalButton(bool selected, VoidCallback onTap) {
|
||||||
|
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||||
|
final defaultColor = AppTheme.secondaryText;
|
||||||
|
return TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPressed: onTap,
|
||||||
|
child: Text(
|
||||||
|
'Personal',
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? highlightColor : defaultColor,
|
||||||
|
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAddButton(VoidCallback onTap) {
|
Widget _buildAddButton(VoidCallback onTap) {
|
||||||
final defaultColor = AppTheme.secondaryText;
|
final defaultColor = AppTheme.secondaryText;
|
||||||
return TextButton(
|
return TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return Colors.transparent;
|
||||||
|
}
|
||||||
|
return null; // Use default for other states (like hover)
|
||||||
|
}),
|
||||||
|
),
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
|
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
|
||||||
);
|
);
|
||||||
@@ -248,24 +381,119 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
orgId = '';
|
orgId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileExplorer(orgId: orgId);
|
return FileExplorer(
|
||||||
|
orgId: orgId,
|
||||||
|
onAudioFileSelected: _onAudioFileSelected,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
Widget _buildNavButton(
|
||||||
|
String label,
|
||||||
|
IconData icon, {
|
||||||
|
bool isAvatar = false,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
bool showSoonBadge = false,
|
||||||
|
}) {
|
||||||
final isSelected = _selectedTab == label;
|
final isSelected = _selectedTab == label;
|
||||||
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||||
final defaultColor = AppTheme.secondaryText;
|
final defaultColor = onTap != null
|
||||||
|
? AppTheme.primaryText
|
||||||
|
: AppTheme.secondaryText;
|
||||||
|
|
||||||
return GestureDetector(
|
Widget button = GestureDetector(
|
||||||
onTap: () {
|
onTap:
|
||||||
setState(() {
|
onTap ??
|
||||||
_selectedTab = label;
|
() {
|
||||||
});
|
setState(() {
|
||||||
},
|
_selectedTab = label;
|
||||||
|
});
|
||||||
|
},
|
||||||
child: isAvatar
|
child: isAvatar
|
||||||
? CircleAvatar(
|
? BlocBuilder<AuthBloc, AuthState>(
|
||||||
backgroundColor: isSelected ? highlightColor : defaultColor,
|
builder: (context, state) {
|
||||||
child: Icon(icon, color: AppTheme.primaryBackground),
|
if (state is AuthAuthenticated &&
|
||||||
|
state.user?.avatarUrl != null &&
|
||||||
|
state.token.isNotEmpty) {
|
||||||
|
String url = state.user!.avatarUrl!;
|
||||||
|
if (!url.contains('token=') && state.token.isNotEmpty) {
|
||||||
|
url = "$url&token=${state.token}";
|
||||||
|
}
|
||||||
|
// Show default avatar while image downloads and display a progress ring
|
||||||
|
return SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// Default placeholder visible under the image/progress
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? highlightColor
|
||||||
|
: defaultColor,
|
||||||
|
child: Icon(icon, color: AppTheme.primaryBackground),
|
||||||
|
),
|
||||||
|
// Network image with loading and error handling
|
||||||
|
ClipOval(
|
||||||
|
child: Image.network(
|
||||||
|
url,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null) return child;
|
||||||
|
final expected =
|
||||||
|
loadingProgress.expectedTotalBytes;
|
||||||
|
final loaded =
|
||||||
|
loadingProgress.cumulativeBytesLoaded;
|
||||||
|
double? value;
|
||||||
|
if (expected != null && expected > 0) {
|
||||||
|
value = loaded / expected;
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// transparent circle so placeholder remains visible
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: value,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
valueColor:
|
||||||
|
AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
backgroundColor: AppTheme.secondaryText
|
||||||
|
.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
// keep placeholder visible on error
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundColor: isSelected ? highlightColor : defaultColor,
|
||||||
|
child: Icon(icon, color: AppTheme.primaryBackground),
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -289,6 +517,36 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (showSoonBadge) {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
button,
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -12,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'soon',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -304,6 +562,123 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
backgroundColor: AppTheme.primaryBackground,
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
// Top bar: title always centered, nav buttons right
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// Centered title
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final screenWidth = MediaQuery.of(
|
||||||
|
context,
|
||||||
|
).size.width;
|
||||||
|
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
|
||||||
|
return Text(
|
||||||
|
'b0esche.cloud',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PixelatedElegance',
|
||||||
|
fontSize: fontSize,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppTheme.primaryText,
|
||||||
|
fontFeatures: const [FontFeature.slashedZero()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_showAudioBar &&
|
||||||
|
_audioFileName != null &&
|
||||||
|
_audioFileUrl != null)
|
||||||
|
Positioned(
|
||||||
|
right: 184,
|
||||||
|
top: 10,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _audioBarOffset,
|
||||||
|
child: AudioPlayerBar(
|
||||||
|
fileName: _audioFileName!,
|
||||||
|
fileUrl: _audioFileUrl!,
|
||||||
|
onClose: () {
|
||||||
|
_audioBarController.reverse();
|
||||||
|
setState(() => _showAudioBar = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Right: nav buttons
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final isLoggedIn = state is AuthAuthenticated;
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return BlocBuilder<
|
||||||
|
OrganizationBloc,
|
||||||
|
OrganizationState
|
||||||
|
>(
|
||||||
|
builder: (context, orgState) {
|
||||||
|
final hasSelectedOrg =
|
||||||
|
orgState is OrganizationLoaded &&
|
||||||
|
orgState.selectedOrg != null;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildNavButton('Drive', Icons.cloud),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_buildNavButton(
|
||||||
|
'Mail',
|
||||||
|
Icons.mail,
|
||||||
|
showSoonBadge: true,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_buildNavButton(
|
||||||
|
'Add',
|
||||||
|
Icons.add,
|
||||||
|
onTap: () =>
|
||||||
|
_showAddFeaturesDialog(context),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
if (hasSelectedOrg)
|
||||||
|
_buildNavButton(
|
||||||
|
'Manage',
|
||||||
|
Icons.manage_accounts_rounded,
|
||||||
|
onTap: () =>
|
||||||
|
_showOrganizationSettings(context),
|
||||||
|
),
|
||||||
|
if (hasSelectedOrg)
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_buildNavButton(
|
||||||
|
'Profile',
|
||||||
|
Icons.person,
|
||||||
|
isAvatar: true,
|
||||||
|
onTap: () =>
|
||||||
|
_showAccountSettings(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Center(
|
Center(
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -316,7 +691,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).size.width < 600
|
top: MediaQuery.of(context).size.width < 600
|
||||||
? 60.0
|
? 96.0
|
||||||
: 78.0,
|
: 78.0,
|
||||||
),
|
),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
@@ -365,6 +740,10 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
path: '/',
|
path: '/',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Reload permissions when org changes
|
||||||
|
context.read<PermissionBloc>().add(
|
||||||
|
LoadPermissions(orgId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child:
|
child:
|
||||||
@@ -402,7 +781,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 80),
|
const SizedBox(height: 16),
|
||||||
_buildOrgRow(context),
|
_buildOrgRow(context),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildDrive(
|
child: _buildDrive(
|
||||||
@@ -554,65 +933,6 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Center(
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
|
|
||||||
return Text(
|
|
||||||
'b0esche.cloud',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'PixelatedElegance',
|
|
||||||
fontSize: fontSize,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
decorationColor: AppTheme.primaryText,
|
|
||||||
fontFeatures: const [FontFeature.slashedZero()],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
|
|
||||||
right: 20,
|
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
final isLoggedIn = state is AuthAuthenticated;
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return ScaleTransition(
|
|
||||||
scale: Tween<double>(begin: 0, end: 1).animate(
|
|
||||||
CurvedAnimation(
|
|
||||||
parent: _animationController,
|
|
||||||
curve: Curves.easeOutBack,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_buildNavButton('Drive', Icons.cloud),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildNavButton('Mail', Icons.mail),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildNavButton('Add', Icons.add),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildNavButton(
|
|
||||||
'Profile',
|
|
||||||
Icons.person,
|
|
||||||
isAvatar: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
137
b0esche_cloud/lib/pages/join_page.dart
Normal file
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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
bool _usePasskey = true;
|
bool _usePasskey = true;
|
||||||
bool _isSignup = false;
|
bool _isSignup = false;
|
||||||
|
|
||||||
|
// UI error state for inline validation
|
||||||
|
String? _usernameErrorText;
|
||||||
|
String? _passwordErrorText;
|
||||||
|
bool _usernameHasError = false;
|
||||||
|
bool _passwordHasError = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
@@ -40,12 +46,6 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateRandomHex(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _generateRandomBase64(int bytes) {
|
String _generateRandomBase64(int bytes) {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||||
@@ -118,6 +118,12 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
_passwordController.clear();
|
_passwordController.clear();
|
||||||
_displayNameController.clear();
|
_displayNameController.clear();
|
||||||
_usePasskey = true;
|
_usePasskey = true;
|
||||||
|
|
||||||
|
// Clear inline error state
|
||||||
|
_usernameHasError = false;
|
||||||
|
_passwordHasError = false;
|
||||||
|
_usernameErrorText = null;
|
||||||
|
_passwordErrorText = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setSignupMode(bool isSignup) {
|
void _setSignupMode(bool isSignup) {
|
||||||
@@ -130,16 +136,54 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
return BlocListener<AuthBloc, AuthState>(
|
return BlocListener<AuthBloc, AuthState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is AuthFailure) {
|
if (state is AuthFailure) {
|
||||||
ScaffoldMessenger.of(
|
// Handle specific credential errors inline
|
||||||
context,
|
if (state.code == 'INVALID_PASSWORD' ||
|
||||||
).showSnackBar(SnackBar(content: Text(state.error)));
|
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 if (state.code == 'NOT_FOUND' ||
|
||||||
|
state.error.toLowerCase().contains('user not found') ||
|
||||||
|
state.error.toLowerCase().contains('not found')) {
|
||||||
|
setState(() {
|
||||||
|
_usernameHasError = true;
|
||||||
|
_usernameErrorText = 'user not found';
|
||||||
|
_usernameController.clear();
|
||||||
|
// clear password error if any
|
||||||
|
_passwordHasError = false;
|
||||||
|
_passwordErrorText = null;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(state.error)));
|
||||||
|
}
|
||||||
} else if (state is AuthenticationChallengeReceived) {
|
} else if (state is AuthenticationChallengeReceived) {
|
||||||
_handleAuthentication(context, state);
|
_handleAuthentication(context, state);
|
||||||
} else if (state is RegistrationChallengeReceived) {
|
} else if (state is RegistrationChallengeReceived) {
|
||||||
_handleRegistration(context, state);
|
_handleRegistration(context, state);
|
||||||
} else if (state is AuthAuthenticated) {
|
} else if (state is AuthAuthenticated) {
|
||||||
context.read<SessionBloc>().add(SessionStarted(state.token));
|
context.read<SessionBloc>().add(SessionStarted(state.token));
|
||||||
context.go('/');
|
final redirect = GoRouterState.of(
|
||||||
|
context,
|
||||||
|
).uri.queryParameters['redirect'];
|
||||||
|
context.go(redirect ?? '/');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -160,7 +204,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_isSignup ? 'create account' : 'sign in',
|
_isSignup ? 'Create Account' : 'Sign In',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
color: AppTheme.primaryText,
|
color: AppTheme.primaryText,
|
||||||
@@ -174,7 +218,9 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
color: _usernameHasError
|
||||||
|
? Colors.red
|
||||||
|
: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -182,6 +228,14 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
cursorColor: AppTheme.accentColor,
|
cursorColor: AppTheme.accentColor,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_usernameHasError || _usernameErrorText != null) {
|
||||||
|
setState(() {
|
||||||
|
_usernameHasError = false;
|
||||||
|
_usernameErrorText = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'username',
|
hintText: 'username',
|
||||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||||
@@ -196,6 +250,17 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
style: const TextStyle(color: AppTheme.primaryText),
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
if (!_isSignup && _usePasskey)
|
if (!_isSignup && _usePasskey)
|
||||||
const SizedBox.shrink()
|
const SizedBox.shrink()
|
||||||
@@ -207,7 +272,9 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
color: _passwordHasError
|
||||||
|
? Colors.red
|
||||||
|
: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -216,6 +283,15 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
keyboardType: TextInputType.visiblePassword,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
cursorColor: AppTheme.accentColor,
|
cursorColor: AppTheme.accentColor,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_passwordHasError ||
|
||||||
|
_passwordErrorText != null) {
|
||||||
|
setState(() {
|
||||||
|
_passwordHasError = false;
|
||||||
|
_passwordErrorText = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'password',
|
hintText: 'password',
|
||||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||||
@@ -230,6 +306,17 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
style: const TextStyle(color: AppTheme.primaryText),
|
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)
|
if (!_isSignup && _usePasskey)
|
||||||
const SizedBox.shrink()
|
const SizedBox.shrink()
|
||||||
else
|
else
|
||||||
@@ -278,20 +365,18 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
isLoading: state is AuthLoading,
|
isLoading: state is AuthLoading,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_usernameController.text.isEmpty) {
|
if (_usernameController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
const SnackBar(
|
_usernameHasError = true;
|
||||||
content: Text('Username is required'),
|
_usernameErrorText = 'username is required';
|
||||||
),
|
});
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isSignup) {
|
if (_isSignup) {
|
||||||
if (_passwordController.text.isEmpty) {
|
if (_passwordController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
const SnackBar(
|
_passwordHasError = true;
|
||||||
content: Text('Password is required'),
|
_passwordErrorText = 'password is required';
|
||||||
),
|
});
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -311,11 +396,11 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (_passwordController.text.isEmpty) {
|
if (_passwordController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
const SnackBar(
|
_passwordHasError = true;
|
||||||
content: Text('Password is required'),
|
_passwordErrorText =
|
||||||
),
|
'password is required';
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -365,7 +450,12 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() => _usePasskey = !_usePasskey);
|
setState(() {
|
||||||
|
_usePasskey = !_usePasskey;
|
||||||
|
// Clear password errors when switching modes
|
||||||
|
_passwordHasError = false;
|
||||||
|
_passwordErrorText = null;
|
||||||
|
});
|
||||||
widget.onPasswordModeChanged?.call(
|
widget.onPasswordModeChanged?.call(
|
||||||
!_usePasskey,
|
!_usePasskey,
|
||||||
);
|
);
|
||||||
|
|||||||
27
b0esche_cloud/lib/pages/login_page.dart
Normal file
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
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,12 +29,6 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateRandomHex(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _generateRandomBase64(int bytes) {
|
String _generateRandomBase64(int bytes) {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||||
|
|||||||
101
b0esche_cloud/lib/pages/video_viewer.dart
Normal file
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class HttpFileRepository implements FileRepository {
|
|||||||
String sourcePath,
|
String sourcePath,
|
||||||
String targetPath,
|
String targetPath,
|
||||||
) async {
|
) async {
|
||||||
throw UnimplementedError();
|
await _fileService.moveFile(orgId, sourcePath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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:dio/dio.dart';
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
import '../models/api_error.dart';
|
import '../models/api_error.dart';
|
||||||
import '../blocs/session/session_bloc.dart';
|
import '../blocs/session/session_bloc.dart';
|
||||||
import '../blocs/session/session_event.dart';
|
import '../blocs/session/session_event.dart';
|
||||||
@@ -13,7 +14,9 @@ class ApiClient {
|
|||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(
|
||||||
|
seconds: 120,
|
||||||
|
), // Increased for file uploads and org operations
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -29,8 +32,13 @@ class ApiClient {
|
|||||||
},
|
},
|
||||||
onError: (error, handler) async {
|
onError: (error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401) {
|
||||||
// Session expired, trigger logout
|
final path = error.requestOptions.path;
|
||||||
_sessionBloc.add(SessionExpired());
|
// 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return handler.next(error);
|
return handler.next(error);
|
||||||
},
|
},
|
||||||
@@ -40,6 +48,8 @@ class ApiClient {
|
|||||||
|
|
||||||
String get baseUrl => _dio.options.baseUrl;
|
String get baseUrl => _dio.options.baseUrl;
|
||||||
|
|
||||||
|
String? get currentToken => _getCurrentToken();
|
||||||
|
|
||||||
String? _getCurrentToken() {
|
String? _getCurrentToken() {
|
||||||
// Get from SessionBloc state
|
// Get from SessionBloc state
|
||||||
final state = _sessionBloc.state;
|
final state = _sessionBloc.state;
|
||||||
@@ -62,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>(
|
Future<T> post<T>(
|
||||||
String path, {
|
String path, {
|
||||||
dynamic data,
|
dynamic data,
|
||||||
@@ -69,12 +91,77 @@ class ApiClient {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post(path, data: data);
|
final response = await _dio.post(path, data: data);
|
||||||
|
|
||||||
return fromJson(response.data);
|
return fromJson(response.data);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw _handleError(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>(
|
Future<List<T>> getList<T>(
|
||||||
String path, {
|
String path, {
|
||||||
Map<String, dynamic>? queryParameters,
|
Map<String, dynamic>? queryParameters,
|
||||||
@@ -82,7 +169,69 @@ class ApiClient {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
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) {
|
} on DioException catch (e) {
|
||||||
throw _handleError(e);
|
throw _handleError(e);
|
||||||
}
|
}
|
||||||
@@ -104,8 +253,17 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String code = data?['code'] ?? 'UNKNOWN';
|
// Only try to extract code/message if data is a Map
|
||||||
String message = data?['message'] ?? 'Unknown error';
|
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);
|
return ApiError(code: code, message: message, status: status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import '../models/file_item.dart';
|
import '../models/file_item.dart';
|
||||||
import '../models/viewer_session.dart';
|
import '../models/viewer_session.dart';
|
||||||
import '../models/editor_session.dart';
|
import '../models/editor_session.dart';
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
@@ -22,6 +24,7 @@ class FileService {
|
|||||||
'/user/files',
|
'/user/files',
|
||||||
queryParameters: pathParam,
|
queryParameters: pathParam,
|
||||||
fromJson: (data) => FileItem(
|
fromJson: (data) => FileItem(
|
||||||
|
id: data['id'],
|
||||||
name: data['name'],
|
name: data['name'],
|
||||||
path: data['path'],
|
path: data['path'],
|
||||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
@@ -35,6 +38,7 @@ class FileService {
|
|||||||
'/orgs/$orgId/files',
|
'/orgs/$orgId/files',
|
||||||
queryParameters: pathParam,
|
queryParameters: pathParam,
|
||||||
fromJson: (data) => FileItem(
|
fromJson: (data) => FileItem(
|
||||||
|
id: data['id'],
|
||||||
name: data['name'],
|
name: data['name'],
|
||||||
path: data['path'],
|
path: data['path'],
|
||||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
@@ -52,32 +56,19 @@ class FileService {
|
|||||||
// If bytes or localPath available, send multipart upload with field 'file'
|
// If bytes or localPath available, send multipart upload with field 'file'
|
||||||
final Map<String, dynamic> fields = {'path': file.path};
|
final Map<String, dynamic> fields = {'path': file.path};
|
||||||
FormData formData;
|
FormData formData;
|
||||||
print(
|
|
||||||
'[FileService] uploadFile: file=${file.name}, path=${file.path}, orgId=$orgId',
|
|
||||||
);
|
|
||||||
print(
|
|
||||||
'[FileService] bytes=${file.bytes?.length ?? 0}, localPath=${file.localPath}',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (file.bytes != null) {
|
if (file.bytes != null) {
|
||||||
print(
|
|
||||||
'[FileService] Using bytes for upload (${file.bytes!.length} bytes)',
|
|
||||||
);
|
|
||||||
formData = FormData.fromMap({
|
formData = FormData.fromMap({
|
||||||
...fields,
|
...fields,
|
||||||
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
||||||
});
|
});
|
||||||
} else if (file.localPath != null) {
|
} else if (file.localPath != null) {
|
||||||
print('[FileService] Using localPath for upload: ${file.localPath}');
|
|
||||||
formData = FormData.fromMap({
|
formData = FormData.fromMap({
|
||||||
...fields,
|
...fields,
|
||||||
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
|
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback to metadata-only create (folders or client that can't send file content)
|
// Fallback to metadata-only create (folders or client that can't send file content)
|
||||||
print(
|
|
||||||
'[FileService] No bytes or localPath; falling back to metadata-only',
|
|
||||||
);
|
|
||||||
final data = {
|
final data = {
|
||||||
'name': file.name,
|
'name': file.name,
|
||||||
'path': file.path,
|
'path': file.path,
|
||||||
@@ -97,9 +88,7 @@ class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||||
print('[FileService] Uploading to endpoint: $endpoint');
|
|
||||||
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
||||||
print('[FileService] Upload completed for ${file.name}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteFile(String orgId, String path) async {
|
Future<void> deleteFile(String orgId, String path) async {
|
||||||
@@ -127,16 +116,51 @@ class FileService {
|
|||||||
return '/orgs/$orgId/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(
|
Future<void> createFolder(
|
||||||
String orgId,
|
String orgId,
|
||||||
String parentPath,
|
String parentPath,
|
||||||
String folderName,
|
String folderName,
|
||||||
) async {
|
) async {
|
||||||
final path = parentPath.endsWith('/')
|
// Normalize folder name to avoid accidental leading slashes creating double-slash paths
|
||||||
? '$parentPath$folderName'
|
final normalizedName = folderName
|
||||||
: '$parentPath/$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 = {
|
final data = {
|
||||||
'name': folderName,
|
'name': normalizedName,
|
||||||
'path': path,
|
'path': path,
|
||||||
'type': 'folder',
|
'type': 'folder',
|
||||||
'size': 0,
|
'size': 0,
|
||||||
@@ -157,7 +181,14 @@ class FileService {
|
|||||||
String sourcePath,
|
String sourcePath,
|
||||||
String targetPath,
|
String targetPath,
|
||||||
) async {
|
) 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 {
|
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||||
@@ -172,11 +203,14 @@ class FileService {
|
|||||||
String orgId,
|
String orgId,
|
||||||
String fileId,
|
String fileId,
|
||||||
) async {
|
) async {
|
||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('fileId cannot be empty');
|
||||||
}
|
}
|
||||||
|
final path = orgId.isEmpty
|
||||||
|
? '/user/files/$fileId/view'
|
||||||
|
: '/orgs/$orgId/files/$fileId/view';
|
||||||
return await _apiClient.get(
|
return await _apiClient.get(
|
||||||
'/orgs/$orgId/files/$fileId/view',
|
path,
|
||||||
fromJson: (data) => ViewerSession.fromJson(data),
|
fromJson: (data) => ViewerSession.fromJson(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -185,11 +219,14 @@ class FileService {
|
|||||||
String orgId,
|
String orgId,
|
||||||
String fileId,
|
String fileId,
|
||||||
) async {
|
) async {
|
||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('fileId cannot be empty');
|
||||||
}
|
}
|
||||||
|
final path = orgId.isEmpty
|
||||||
|
? '/user/files/$fileId/edit'
|
||||||
|
: '/orgs/$orgId/files/$fileId/edit';
|
||||||
return await _apiClient.get(
|
return await _apiClient.get(
|
||||||
'/orgs/$orgId/files/$fileId/edit',
|
path,
|
||||||
fromJson: (data) => EditorSession.fromJson(data),
|
fromJson: (data) => EditorSession.fromJson(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,11 +236,14 @@ class FileService {
|
|||||||
String fileId,
|
String fileId,
|
||||||
List<Annotation> annotations,
|
List<Annotation> annotations,
|
||||||
) async {
|
) async {
|
||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('fileId cannot be empty');
|
||||||
}
|
}
|
||||||
|
final path = orgId.isEmpty
|
||||||
|
? '/user/files/$fileId/annotations'
|
||||||
|
: '/orgs/$orgId/files/$fileId/annotations';
|
||||||
await _apiClient.post(
|
await _apiClient.post(
|
||||||
'/orgs/$orgId/files/$fileId/annotations',
|
path,
|
||||||
data: {
|
data: {
|
||||||
'annotations': annotations.map((a) => a.toJson()).toList(),
|
'annotations': annotations.map((a) => a.toJson()).toList(),
|
||||||
'baseVersionId': '1', // mock
|
'baseVersionId': '1', // mock
|
||||||
@@ -211,4 +251,149 @@ class FileService {
|
|||||||
fromJson: (data) => null,
|
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 '../blocs/organization/organization_state.dart';
|
||||||
|
import '../models/organization.dart';
|
||||||
|
import '../models/user.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
class OrgApi {
|
class OrgApi {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
@@ -14,10 +17,127 @@ class OrgApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Organization> createOrganization(String name) async {
|
Future<Organization> createOrganization(String name) async {
|
||||||
return await _apiClient.post(
|
developer.log('POST /orgs with payload: {"name": "$name"}', name: 'OrgApi');
|
||||||
'/orgs',
|
|
||||||
data: {'name': name},
|
try {
|
||||||
fromJson: (data) => Organization.fromJson(data),
|
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 {
|
class AppTheme {
|
||||||
static const Color primaryBackground = Colors.black;
|
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 accentColor = Color.fromARGB(255, 100, 200, 255);
|
||||||
static const Color secondaryText = Colors.white70;
|
static const Color secondaryText = Colors.white70;
|
||||||
static const Color primaryText = Colors.white;
|
static const Color primaryText = Colors.white;
|
||||||
|
static const Color errorColor = Colors.redAccent;
|
||||||
static const Color glassBackground = Colors.white;
|
static const Color glassBackground = Colors.white;
|
||||||
static const double glassOpacity = 0.1;
|
static const double glassOpacity = 0.1;
|
||||||
static const double glassBlur = 10;
|
static const double glassBlur = 10;
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ class ModernGlassButton extends StatefulWidget {
|
|||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
final EdgeInsets padding;
|
||||||
|
final bool showShadows;
|
||||||
|
|
||||||
const ModernGlassButton({
|
const ModernGlassButton({
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
this.showShadows = true,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,34 +65,32 @@ class _ModernGlassButtonState extends State<ModernGlassButton>
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Shadow layer
|
// Shadow layer
|
||||||
Container(
|
if (widget.showShadows)
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
borderRadius: BorderRadius.circular(12),
|
decoration: BoxDecoration(
|
||||||
boxShadow: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
BoxShadow(
|
boxShadow: [
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
BoxShadow(
|
||||||
blurRadius: _isHovered ? 24 : 12,
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
spreadRadius: _isHovered ? 2 : 0,
|
blurRadius: _isHovered ? 24 : 12,
|
||||||
offset: const Offset(0, 8),
|
spreadRadius: _isHovered ? 2 : 0,
|
||||||
),
|
offset: const Offset(0, 8),
|
||||||
BoxShadow(
|
),
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.1),
|
BoxShadow(
|
||||||
blurRadius: 20,
|
color: AppTheme.accentColor.withValues(alpha: 0.1),
|
||||||
spreadRadius: 5,
|
blurRadius: 20,
|
||||||
),
|
spreadRadius: 5,
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// Glass button with gradient
|
// Glass button with gradient
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: widget.padding,
|
||||||
horizontal: 24,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
gradient: LinearGradient(
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1758
b0esche_cloud/lib/widgets/account_settings_dialog.dart
Normal file
1758
b0esche_cloud/lib/widgets/account_settings_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
284
b0esche_cloud/lib/widgets/add_features_dialog.dart
Normal file
284
b0esche_cloud/lib/widgets/add_features_dialog.dart
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class AddFeaturesDialog extends StatelessWidget {
|
||||||
|
const AddFeaturesDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Container(
|
||||||
|
width: 500,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Add Features',
|
||||||
|
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),
|
||||||
|
splashRadius: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Enhance your workspace with additional features',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Features list
|
||||||
|
_FeatureCard(
|
||||||
|
icon: Icons.calendar_month,
|
||||||
|
title: 'Calendar',
|
||||||
|
description:
|
||||||
|
'Schedule events, set reminders, and manage your time efficiently. Sync with your team and never miss important deadlines.',
|
||||||
|
onAdd: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Calendar feature coming soon!'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FeatureCard(
|
||||||
|
icon: Icons.dashboard_customize,
|
||||||
|
title: 'Board',
|
||||||
|
description:
|
||||||
|
'Organize tasks with kanban-style boards. Create columns, drag cards, and track progress visually across your projects.',
|
||||||
|
onAdd: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Board feature coming soon!')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Footer hint
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'More features will be available soon. Stay tuned!',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeatureCard extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
|
||||||
|
const _FeatureCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.onAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FeatureCard> createState() => _FeatureCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeatureCardState extends State<_FeatureCard> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isHovered
|
||||||
|
? Colors.white.withValues(alpha: 0.08)
|
||||||
|
: Colors.white.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _isHovered
|
||||||
|
? AppTheme.accentColor.withValues(alpha: 0.5)
|
||||||
|
: Colors.white.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Feature icon with soon badge
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
AppTheme.accentColor.withValues(alpha: 0.1),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
widget.icon,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 5,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'soon',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Feature details
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.description,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Add button
|
||||||
|
_AddButton(onPressed: widget.onAdd),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddButton extends StatefulWidget {
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const _AddButton({required this.onPressed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddButton> createState() => _AddButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddButtonState extends State<_AddButton> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isHovered
|
||||||
|
? AppTheme.accentColor
|
||||||
|
: AppTheme.accentColor.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.add,
|
||||||
|
color: _isHovered ? Colors.black : AppTheme.accentColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
312
b0esche_cloud/lib/widgets/audio_player_bar.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
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
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
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
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 FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import audio_session
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
import desktop_drop
|
import desktop_drop
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_picker
|
import file_picker
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
import irondash_engine_context
|
import irondash_engine_context
|
||||||
import path_provider_foundation
|
import just_audio
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import super_native_extensions
|
import super_native_extensions
|
||||||
import syncfusion_pdfviewer_macos
|
import syncfusion_pdfviewer_macos
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
import video_player_avfoundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
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"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
|
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
|
||||||
SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin"))
|
SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
_fe_analyzer_shared:
|
archive:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: archive
|
||||||
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
|
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "67.0.0"
|
version: "4.0.7"
|
||||||
analyzer:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: analyzer
|
|
||||||
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.4.1"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,94 +25,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
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:
|
bloc:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: bloc
|
name: bloc
|
||||||
sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d
|
sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.1.0"
|
version: "9.2.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"
|
|
||||||
cached_network_image:
|
cached_network_image:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -149,26 +69,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -177,14 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
code_builder:
|
code_assets:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_builder
|
name: code_assets
|
||||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.11.0"
|
version: "1.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -217,22 +121,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
coverage:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: coverage
|
|
||||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.15.0"
|
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cross_file
|
name: cross_file
|
||||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+1"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -241,22 +137,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
dart_style:
|
csslib:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: csslib
|
||||||
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.6"
|
version: "1.0.2"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
desktop_drop:
|
desktop_drop:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -281,22 +177,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.3"
|
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:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dio
|
name: dio
|
||||||
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.9.0"
|
version: "5.9.1"
|
||||||
dio_web_adapter:
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -309,26 +197,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: equatable
|
name: equatable
|
||||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.7"
|
version: "2.0.8"
|
||||||
fake_async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: fake_async
|
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.3"
|
|
||||||
ffi:
|
ffi:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -341,10 +221,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200"
|
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.3.7"
|
version: "10.3.10"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -402,10 +282,10 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_lints
|
name: flutter_lints
|
||||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "5.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -478,24 +358,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
version: "2.2.3"
|
||||||
flutter_test:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
get_it:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -516,18 +383,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
|
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.0.1"
|
version: "17.1.0"
|
||||||
graphs:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: graphs
|
|
||||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.3.2"
|
|
||||||
hive:
|
hive:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -544,38 +403,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
hive_generator:
|
hooks:
|
||||||
dependency: "direct dev"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hive_generator
|
name: hooks
|
||||||
sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4"
|
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "1.0.1"
|
||||||
http:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
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:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http_parser
|
name: http_parser
|
||||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
infinite_scroll_pagination:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -588,18 +455,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: injectable
|
name: injectable
|
||||||
sha256: "8fc24421cfeff76d1d38484d8b9617beeb54a58b6edfd002b10cc896b8b8f3fe"
|
sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.1+2"
|
version: "2.7.1+4"
|
||||||
injectable_generator:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: injectable_generator
|
|
||||||
sha256: af403d76c7b18b4217335e0075e950cd0579fd7f8d7bd47ee7c85ada31680ba1
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.6.2"
|
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -608,14 +467,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
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:
|
irondash_engine_context:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -632,62 +483,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0"
|
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
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: just_audio_platform_interface
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "4.6.0"
|
||||||
json_annotation:
|
just_audio_web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: just_audio_web
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "0.4.16"
|
||||||
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"
|
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "5.1.1"
|
||||||
logger:
|
logger:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -704,22 +531,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
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:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -736,22 +555,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
mockito:
|
native_toolchain_c:
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: mockito
|
|
||||||
sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.4.4"
|
|
||||||
mocktail:
|
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mocktail
|
name: native_toolchain_c
|
||||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "0.17.4"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -768,14 +579,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
node_preamble:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: node_preamble
|
name: objective_c
|
||||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "9.3.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -784,14 +595,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -828,10 +631,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.1"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -888,14 +691,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
pool:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pool
|
name: posix
|
||||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "6.0.3"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -912,22 +715,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -948,10 +735,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.18"
|
version: "2.4.20"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -992,38 +779,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -1037,46 +792,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.12"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.2"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1117,30 +840,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
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:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1166,7 +865,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.1"
|
version: "0.9.1"
|
||||||
syncfusion_flutter_core:
|
syncfusion_flutter_core:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: syncfusion_flutter_core
|
name: syncfusion_flutter_core
|
||||||
sha256: e1fdfcc3ed7e1f040ba95838780b2eb1857e3e5eccb817fbe94ea2b09c35eac4
|
sha256: e1fdfcc3ed7e1f040ba95838780b2eb1857e3e5eccb817fbe94ea2b09c35eac4
|
||||||
@@ -1253,38 +952,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
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:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1302,7 +969,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
@@ -1321,10 +988,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.6"
|
version: "6.4.1"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1353,10 +1020,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1393,10 +1060,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics_compiler
|
name: vector_graphics_compiler
|
||||||
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.19"
|
version: "1.1.20"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1405,54 +1072,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
vm_service:
|
video_player:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: video_player
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "2.10.1"
|
||||||
watcher:
|
video_player_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: video_player_android
|
||||||
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "2.9.1"
|
||||||
|
video_player_avfoundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_avfoundation
|
||||||
|
sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.3"
|
||||||
|
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:
|
web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1495,4 +1162,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.4 <4.0.0"
|
dart: ">=3.10.4 <4.0.0"
|
||||||
flutter: ">=3.35.1"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: b0esche_cloud
|
name: b0esche
|
||||||
description: "A new Flutter project."
|
description: "b0esche secure cloud"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ dependencies:
|
|||||||
|
|
||||||
# Networking
|
# Networking
|
||||||
dio: ^5.3.2
|
dio: ^5.3.2
|
||||||
|
http_parser: ^4.0.2
|
||||||
|
|
||||||
# Routing
|
# Routing
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
@@ -48,6 +49,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
connectivity_plus: ^7.0.0
|
connectivity_plus: ^7.0.0
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
|
url_launcher: ^6.2.2
|
||||||
file_picker: ^10.3.7
|
file_picker: ^10.3.7
|
||||||
flutter_dropzone: ^4.0.0
|
flutter_dropzone: ^4.0.0
|
||||||
desktop_drop: ^0.7.0
|
desktop_drop: ^0.7.0
|
||||||
@@ -55,27 +57,28 @@ dependencies:
|
|||||||
infinite_scroll_pagination: ^5.1.1
|
infinite_scroll_pagination: ^5.1.1
|
||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
syncfusion_flutter_pdfviewer: ^31.1.21
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_lints: ^5.0.0
|
||||||
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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/fonts/
|
- assets/fonts/
|
||||||
|
- assets/icons/.
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: PixelatedElegance
|
- 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()],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<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 -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -30,31 +30,42 @@
|
|||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
|
||||||
<!-- Preload fonts -->
|
<!-- Preload PixelatedElegance brand font -->
|
||||||
<link rel="preload" href="assets/fonts/veteran-typewriter/veteran_typewriter.ttf" as="font" type="font/ttf"
|
<link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font"
|
||||||
crossorigin>
|
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>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'VeteranTypewriter';
|
font-family: 'PixelatedElegance';
|
||||||
src: url('assets/fonts/veteran-typewriter/veteran_typewriter.ttf') format('truetype');
|
src: url('assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.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');
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title>b0esche_cloud</title>
|
<title>b0esche_cloud</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "b0esche_cloud",
|
"name": "b0esche.cloud",
|
||||||
"short_name": "b0esche_cloud",
|
"short_name": "b0esche",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#0175C2",
|
||||||
"description": "A new Flutter project.",
|
"description": "b0esche secure cloud",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
@@ -32,4 +32,4 @@
|
|||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project-level configuration.
|
# Project-level configuration.
|
||||||
cmake_minimum_required(VERSION 3.14)
|
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 name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
|
|||||||
Binary file not shown.
596
docs/API.md
Normal file
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
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
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
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
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
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 |
|
||||||
BIN
go_cloud/api
BIN
go_cloud/api
Binary file not shown.
BIN
go_cloud/bin/api
BIN
go_cloud/bin/api
Binary file not shown.
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/audit"
|
"go.b0esche.cloud/backend/internal/audit"
|
||||||
"go.b0esche.cloud/backend/internal/auth"
|
"go.b0esche.cloud/backend/internal/auth"
|
||||||
@@ -13,9 +14,43 @@ import (
|
|||||||
"go.b0esche.cloud/backend/pkg/jwt"
|
"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() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// Ensure avatar cache directory is usable and persistent when possible
|
||||||
|
ensureAvatarCacheDir(cfg)
|
||||||
|
|
||||||
dbConn, err := database.Connect(cfg)
|
dbConn, err := database.Connect(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgconn v1.13.0
|
||||||
github.com/jackc/pgx/v5 v5.7.6
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
golang.org/x/crypto v0.37.0
|
golang.org/x/crypto v0.37.0
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.28.0
|
||||||
@@ -14,7 +15,10 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // 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/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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
|||||||
118
go_cloud/go.sum
118
go_cloud/go.sum
@@ -1,5 +1,9 @@
|
|||||||
|
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 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -7,38 +11,152 @@ 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-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 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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.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.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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
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 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
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 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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")
|
|
||||||
}
|
|
||||||
@@ -1,37 +1,49 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
OIDCIssuerURL string
|
OIDCIssuerURL string
|
||||||
OIDCRedirectURL string
|
OIDCRedirectURL string
|
||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
OIDCClientSecret string
|
OIDCClientSecret string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
NextcloudURL string
|
NextcloudURL string
|
||||||
NextcloudUser string
|
NextcloudUser string
|
||||||
NextcloudPass string
|
NextcloudPass string
|
||||||
NextcloudBase string
|
NextcloudBase string
|
||||||
|
AllowedOrigins string
|
||||||
|
AvatarCacheDir string
|
||||||
|
AvatarDownloadTimeoutSeconds int
|
||||||
|
AvatarDownloadRetries int
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
cfg := &Config{
|
||||||
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
||||||
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
||||||
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
||||||
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||||
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
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 {
|
func getEnv(key, defaultVal string) string {
|
||||||
@@ -40,3 +52,12 @@ func getEnv(key, defaultVal string) string {
|
|||||||
}
|
}
|
||||||
return defaultVal
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.b0esche.cloud/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
@@ -16,14 +20,57 @@ func New(db *sql.DB) *DB {
|
|||||||
return &DB{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 {
|
type User struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID `json:"id"`
|
||||||
Email string
|
Email string `json:"email"`
|
||||||
Username string
|
Username string `json:"username"`
|
||||||
DisplayName string
|
DisplayName string `json:"displayName"`
|
||||||
PasswordHash *string
|
PasswordHash *string `json:"-"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
LastLoginAt *time.Time
|
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Credential struct {
|
type Credential struct {
|
||||||
@@ -34,7 +81,7 @@ type Credential struct {
|
|||||||
SignCount int64
|
SignCount int64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastUsedAt *time.Time
|
LastUsedAt *time.Time
|
||||||
Transports []string
|
Transports StringArray
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthChallenge struct {
|
type AuthChallenge struct {
|
||||||
@@ -55,10 +102,12 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Organization struct {
|
type Organization struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
OwnerID uuid.UUID `json:"ownerId"`
|
||||||
Slug string `json:"slug"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Slug string `json:"slug"`
|
||||||
|
InviteLinkToken *string `json:"inviteLinkToken,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Membership struct {
|
type Membership struct {
|
||||||
@@ -68,6 +117,26 @@ type Membership struct {
|
|||||||
CreatedAt time.Time
|
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 {
|
type Activity struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
@@ -79,15 +148,17 @@ type Activity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
OrgID *uuid.UUID
|
OrgID *uuid.UUID
|
||||||
UserID *uuid.UUID
|
UserID *uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
Type string
|
Type string
|
||||||
Size int64
|
Size int64
|
||||||
LastModified time.Time
|
LastModified time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
ModifiedBy *uuid.UUID
|
||||||
|
ModifiedByName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||||
@@ -143,7 +214,7 @@ func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
|||||||
|
|
||||||
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
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
|
FROM organizations o
|
||||||
JOIN memberships m ON o.id = m.org_id
|
JOIN memberships m ON o.id = m.org_id
|
||||||
WHERE m.user_id = $1
|
WHERE m.user_id = $1
|
||||||
@@ -156,7 +227,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
|
|||||||
var orgs []Organization
|
var orgs []Organization
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var org Organization
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
orgs = append(orgs, org)
|
orgs = append(orgs, org)
|
||||||
@@ -177,13 +248,20 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
|
|||||||
return &membership, nil
|
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
|
var org Organization
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
INSERT INTO organizations (name, slug)
|
INSERT INTO organizations (owner_id, name, slug, invite_link_token)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, name, slug, created_at
|
RETURNING id, owner_id, name, slug, invite_link_token, created_at
|
||||||
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
|
`, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -254,8 +332,274 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
|||||||
return memberships, rows.Err()
|
return memberships, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
`, 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)
|
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||||
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
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 {
|
if page <= 0 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -264,15 +608,31 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q s
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
// Basic search and pagination. Returns files under the given path (including nested).
|
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, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
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
|
FROM files f
|
||||||
WHERE org_id = $1 AND path != $2 AND path LIKE $2 || '/%'
|
WHERE f.org_id = $1
|
||||||
AND ($4 = '' OR name ILIKE '%' || $4 || '%')
|
AND EXISTS (
|
||||||
ORDER BY name
|
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
|
LIMIT $5 OFFSET $6
|
||||||
`, orgID, path, path, q, pageSize, offset)
|
`, orgID, userID, path, q, pageSize, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -296,7 +656,60 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q s
|
|||||||
}
|
}
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
return files, rows.Err()
|
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
|
// GetUserFiles returns files for a user's personal workspace at a given path
|
||||||
@@ -309,12 +722,20 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
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, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
FROM files
|
FROM files
|
||||||
WHERE user_id = $1 AND path LIKE $2 || '%'
|
WHERE user_id = $1
|
||||||
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
AND org_id IS NULL
|
||||||
ORDER BY name
|
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
|
LIMIT $4 OFFSET $5
|
||||||
`, userID, path, q, pageSize, offset)
|
`, userID, path, q, pageSize, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -340,7 +761,54 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
|
|||||||
}
|
}
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
return files, rows.Err()
|
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.
|
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
||||||
@@ -348,16 +816,21 @@ func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUI
|
|||||||
var f File
|
var f File
|
||||||
var orgIDVal interface{}
|
var orgIDVal interface{}
|
||||||
var userIDVal interface{}
|
var userIDVal interface{}
|
||||||
|
orgIDStr := ""
|
||||||
|
userIDStr := ""
|
||||||
if orgID != nil {
|
if orgID != nil {
|
||||||
orgIDVal = *orgID
|
orgIDVal = *orgID
|
||||||
|
orgIDStr = orgID.String()
|
||||||
} else {
|
} else {
|
||||||
orgIDVal = nil
|
orgIDVal = nil
|
||||||
}
|
}
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
userIDVal = *userID
|
userIDVal = *userID
|
||||||
|
userIDStr = userID.String()
|
||||||
} else {
|
} else {
|
||||||
userIDVal = nil
|
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, `
|
err := db.QueryRowContext(ctx, `
|
||||||
INSERT INTO files (org_id, user_id, name, path, type, size)
|
INSERT INTO files (org_id, user_id, name, path, type, size)
|
||||||
@@ -367,9 +840,159 @@ func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
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
|
// 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 {
|
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
|
||||||
var res sql.Result
|
var res sql.Result
|
||||||
@@ -388,15 +1011,6 @@ func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uu
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
|
||||||
_, err := db.ExecContext(ctx, `
|
|
||||||
UPDATE memberships
|
|
||||||
SET role = $1
|
|
||||||
WHERE org_id = $2 AND user_id = $3
|
|
||||||
`, role, orgID, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passkey-related methods
|
// Passkey-related methods
|
||||||
|
|
||||||
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
|
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
|
||||||
@@ -546,3 +1160,113 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
|
|||||||
`, challenge)
|
`, challenge)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ import (
|
|||||||
type ErrorCode string
|
type ErrorCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||||
|
// More specific authentication error codes
|
||||||
|
CodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
|
||||||
|
CodeInvalidPassword ErrorCode = "INVALID_PASSWORD"
|
||||||
|
|
||||||
CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
|
CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
|
||||||
CodeNotFound ErrorCode = "NOT_FOUND"
|
CodeNotFound ErrorCode = "NOT_FOUND"
|
||||||
CodeConflict ErrorCode = "CONFLICT"
|
CodeConflict ErrorCode = "CONFLICT"
|
||||||
|
CodeAlreadyExists ErrorCode = "ALREADY_EXISTS"
|
||||||
CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
|
CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
|
||||||
CodeInternal ErrorCode = "INTERNAL"
|
CodeInternal ErrorCode = "INTERNAL"
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
846
go_cloud/internal/http/wopi_handlers.go
Normal file
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/audit"
|
"go.b0esche.cloud/backend/internal/audit"
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
@@ -21,70 +25,247 @@ var RequestID = middleware.RequestID
|
|||||||
var Logger = middleware.Logger
|
var Logger = middleware.Logger
|
||||||
var Recoverer = middleware.Recoverer
|
var Recoverer = middleware.Recoverer
|
||||||
|
|
||||||
// CORS middleware
|
// SecurityHeaders adds security-related HTTP headers
|
||||||
func CORS(next http.Handler) http.Handler {
|
func SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return func(next http.Handler) http.Handler {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
// Prevent MIME type sniffing
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
// CORS middleware - accepts allowedOrigins comma-separated string
|
||||||
w.WriteHeader(http.StatusOK)
|
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) {
|
||||||
|
// 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
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement rate limiter
|
type ContextKey string
|
||||||
var RateLimit = func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Basic rate limiting logic here
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userKey contextKey = "user"
|
UserKey ContextKey = "user"
|
||||||
sessionKey contextKey = "session"
|
SessionKey ContextKey = "session"
|
||||||
orgKey contextKey = "org"
|
TokenKey ContextKey = "token"
|
||||||
|
OrgKey ContextKey = "org"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetUserID retrieves the user ID from the request context
|
// GetUserID retrieves the user ID from the request context
|
||||||
func GetUserID(ctx context.Context) (string, bool) {
|
func GetUserID(ctx context.Context) (string, bool) {
|
||||||
userID, ok := ctx.Value(userKey).(string)
|
userID, ok := ctx.Value(UserKey).(string)
|
||||||
return userID, ok
|
return userID, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession retrieves the session from the request context
|
// GetSession retrieves the session from the request context
|
||||||
func GetSession(ctx context.Context) (*database.Session, bool) {
|
func GetSession(ctx context.Context) (*database.Session, bool) {
|
||||||
session, ok := ctx.Value(sessionKey).(*database.Session)
|
session, ok := ctx.Value(SessionKey).(*database.Session)
|
||||||
return session, ok
|
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
|
// Auth middleware
|
||||||
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
var tokenString string
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
var tokenSource string
|
||||||
return
|
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)
|
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
|
fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
|
||||||
ctx = context.WithValue(ctx, sessionKey, session)
|
|
||||||
|
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))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -94,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 {
|
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
|
||||||
orgIDStr := r.Header.Get("X-Org-ID")
|
orgIDStr := r.Header.Get("X-Org-ID")
|
||||||
@@ -120,20 +301,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
|
ctx := context.WithValue(r.Context(), OrgKey, 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)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -143,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 {
|
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
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)
|
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
|
||||||
if err != nil || !hasPerm {
|
if err != nil || !hasPerm {
|
||||||
|
|||||||
20
go_cloud/internal/models/file_share_link.go
Normal file
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
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveUserOrgs returns the organizations a user belongs to
|
// 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
|
// 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) {
|
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
|
||||||
if slug == "" {
|
trimmedName := strings.TrimSpace(name)
|
||||||
// Simple slug generation
|
if trimmedName == "" {
|
||||||
slug = name // TODO: make URL safe
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 nil, err
|
||||||
}
|
}
|
||||||
return org, nil
|
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{
|
var rolePermissions = map[string][]Permission{
|
||||||
"owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
|
"owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
|
||||||
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit},
|
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
|
||||||
"editor": {FileRead, FileWrite, DocumentView, DocumentEdit},
|
"member": {FileRead, DocumentView},
|
||||||
"viewer": {FileRead, DocumentView},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPermission checks if user has permission in org
|
// HasPermission checks if user has permission in org
|
||||||
|
|||||||
78
go_cloud/internal/storage/nextcloud.go
Normal file
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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/config"
|
"go.b0esche.cloud/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebDAVClient struct {
|
type WebDAVClient struct {
|
||||||
baseURL string
|
BaseURL string
|
||||||
user string
|
user string
|
||||||
pass string
|
pass string
|
||||||
basePrefix string
|
basePrefix string
|
||||||
@@ -23,19 +25,24 @@ type WebDAVClient struct {
|
|||||||
// NewWebDAVClient returns nil if no Nextcloud URL configured
|
// NewWebDAVClient returns nil if no Nextcloud URL configured
|
||||||
func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
|
func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
|
||||||
if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" {
|
if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" {
|
||||||
|
log.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
u := strings.TrimRight(cfg.NextcloudURL, "/")
|
u := strings.TrimRight(cfg.NextcloudURL, "/")
|
||||||
|
if !strings.Contains(u, "/remote.php") {
|
||||||
|
u += "/remote.php/dav/files/" + cfg.NextcloudUser
|
||||||
|
}
|
||||||
base := cfg.NextcloudBase
|
base := cfg.NextcloudBase
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = "/"
|
base = "/"
|
||||||
}
|
}
|
||||||
|
log.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base)
|
||||||
return &WebDAVClient{
|
return &WebDAVClient{
|
||||||
baseURL: u,
|
BaseURL: u,
|
||||||
user: cfg.NextcloudUser,
|
user: cfg.NextcloudUser,
|
||||||
pass: cfg.NextcloudPass,
|
pass: cfg.NextcloudPass,
|
||||||
basePrefix: strings.TrimRight(base, "/"),
|
basePrefix: strings.TrimRight(base, "/"),
|
||||||
httpClient: &http.Client{},
|
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +58,10 @@ func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) erro
|
|||||||
cur := c.basePrefix
|
cur := c.basePrefix
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
cur = path.Join(cur, p)
|
cur = path.Join(cur, p)
|
||||||
mkurl := fmt.Sprintf("%s%s", c.baseURL, cur)
|
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)
|
req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil)
|
||||||
if c.user != "" {
|
if c.user != "" {
|
||||||
req.SetBasicAuth(c.user, c.pass)
|
req.SetBasicAuth(c.user, c.pass)
|
||||||
@@ -60,11 +70,15 @@ func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Read body for diagnostics
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
// 201 created, 405 exists — ignore
|
// 201 created, 405 exists — ignore
|
||||||
if resp.StatusCode == 201 || resp.StatusCode == 405 {
|
if resp.StatusCode == 201 || resp.StatusCode == 405 {
|
||||||
continue
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -74,20 +88,31 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("no webdav client configured")
|
return fmt.Errorf("no webdav client configured")
|
||||||
}
|
}
|
||||||
// Ensure parent collections
|
// Ensure parent collections, skip for .avatars as it should exist
|
||||||
if err := c.ensureParent(ctx, remotePath); err != nil {
|
if !strings.HasPrefix(remotePath, ".avatars/") {
|
||||||
return err
|
if err := c.ensureParent(ctx, remotePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Construct URL
|
// Construct URL
|
||||||
// remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix
|
// remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix
|
||||||
rel := strings.TrimLeft(remotePath, "/")
|
rel := strings.TrimLeft(remotePath, "/")
|
||||||
u := c.basePrefix
|
u := c.basePrefix
|
||||||
if u == "/" || u == "" {
|
if u == "/" || 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 := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
|
||||||
full = strings.ReplaceAll(full, "%2F", "/")
|
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)
|
req, err := http.NewRequestWithContext(ctx, "PUT", full, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -107,45 +132,58 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
return nil
|
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)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
|
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download retrieves a file from the remotePath using HTTP GET (WebDAV).
|
// Download retrieves a file from the remotePath using HTTP GET (WebDAV).
|
||||||
func (c *WebDAVClient) Download(ctx context.Context, remotePath string) (io.ReadCloser, int64, error) {
|
func (c *WebDAVClient) Download(ctx context.Context, remotePath string, rangeHeader string) (*http.Response, error) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil, 0, fmt.Errorf("no webdav client configured")
|
return nil, fmt.Errorf("no webdav client configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
rel := strings.TrimLeft(remotePath, "/")
|
rel := strings.TrimLeft(remotePath, "/")
|
||||||
u := c.basePrefix
|
u := c.basePrefix
|
||||||
if u == "/" || u == "" {
|
if u == "/" || 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 := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
|
||||||
full = strings.ReplaceAll(full, "%2F", "/")
|
full = strings.ReplaceAll(full, "%2F", "/")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", full, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", full, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if c.user != "" {
|
if c.user != "" {
|
||||||
req.SetBasicAuth(c.user, c.pass)
|
req.SetBasicAuth(c.user, c.pass)
|
||||||
}
|
}
|
||||||
|
if rangeHeader != "" {
|
||||||
|
req.Header.Set("Range", rangeHeader)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
return resp.Body, resp.ContentLength, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return nil, 0, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(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).
|
// Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV).
|
||||||
@@ -157,9 +195,16 @@ func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
|
|||||||
rel := strings.TrimLeft(remotePath, "/")
|
rel := strings.TrimLeft(remotePath, "/")
|
||||||
u := c.basePrefix
|
u := c.basePrefix
|
||||||
if u == "/" || u == "" {
|
if u == "/" || 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 := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
|
||||||
full = strings.ReplaceAll(full, "%2F", "/")
|
full = strings.ReplaceAll(full, "%2F", "/")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil)
|
req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil)
|
||||||
@@ -188,3 +233,64 @@ func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
|
|||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(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))
|
||||||
|
}
|
||||||
|
|||||||
28
go_cloud/migrations/0004_org_owner_slug.sql
Normal file
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
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
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
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
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;
|
||||||
@@ -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;
|
||||||
1
go_cloud/migrations/0010_add_avatar_url.sql
Normal file
1
go_cloud/migrations/0010_add_avatar_url.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN avatar_url TEXT;
|
||||||
1
go_cloud/migrations/0010_add_avatar_url_down.sql
Normal file
1
go_cloud/migrations/0010_add_avatar_url_down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN avatar_url;
|
||||||
1
go_cloud/migrations/0011_add_updated_at.sql
Normal file
1
go_cloud/migrations/0011_add_updated_at.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
|
||||||
@@ -31,16 +31,48 @@ run_migration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Run migrations in order
|
# Run migrations in order
|
||||||
echo "Step 1/3: Initial schema..."
|
echo "Step 1/11: Initial schema..."
|
||||||
run_migration "$SCRIPT_DIR/0001_initial.sql"
|
run_migration "$SCRIPT_DIR/0001_initial.sql"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Step 2/3: Passkeys and authentication..."
|
echo "Step 2/11: Passkeys and authentication..."
|
||||||
run_migration "$SCRIPT_DIR/0002_passkeys.sql"
|
run_migration "$SCRIPT_DIR/0002_passkeys.sql"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Step 3/3: Files and storage..."
|
echo "Step 3/11: Files and storage..."
|
||||||
run_migration "$SCRIPT_DIR/0003_files.sql"
|
run_migration "$SCRIPT_DIR/0003_files.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 4/11: Organization ownership and slug scope..."
|
||||||
|
run_migration "$SCRIPT_DIR/0004_org_owner_slug.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 5/11: Organization invitations and join requests..."
|
||||||
|
run_migration "$SCRIPT_DIR/0005_org_invitations.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 6/11: Organization invite links..."
|
||||||
|
run_migration "$SCRIPT_DIR/0006_org_invite_link.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 7/11: File share links..."
|
||||||
|
run_migration "$SCRIPT_DIR/0007_file_share_links.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 8/11: File share links nullable org..."
|
||||||
|
run_migration "$SCRIPT_DIR/0008_file_share_links_nullable_org.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 9/11: File share links org id nullable..."
|
||||||
|
run_migration "$SCRIPT_DIR/0009_file_share_links_org_id_nullable.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 10/11: Add avatar URL to users..."
|
||||||
|
run_migration "$SCRIPT_DIR/0010_add_avatar_url.sql"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Step 11/11: Add updated_at to users..."
|
||||||
|
run_migration "$SCRIPT_DIR/0011_add_updated_at.sql"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== All migrations completed successfully! ==="
|
echo "=== All migrations completed successfully! ==="
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ func NewManager(secret string) *Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) {
|
func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) {
|
||||||
|
return m.GenerateWithDuration(userID, orgIDs, sessionID, 15*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GenerateWithDuration(userID string, orgIDs []string, sessionID string, duration time.Duration) (string, error) {
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
OrgIDs: orgIDs,
|
OrgIDs: orgIDs,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
107
scripts/auto-deploy.sh
Executable file
107
scripts/auto-deploy.sh
Executable file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Daily auto-deploy script for Flutter/Go projects
|
||||||
|
# Runs at 3AM daily - no more disruptive deployments!
|
||||||
|
# Run as root/admin - no extra users needed!
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
GIT_REPO="https://lab.b0esche.cloud/b0esche/b0esche_cloud.git"
|
||||||
|
DEPLOY_DIR="/opt/auto-deploy/b0esche_cloud_rollout"
|
||||||
|
BUILD_DIR="/opt/go/data/postgres/backend/go_cloud"
|
||||||
|
LOG_FILE="/var/log/auto-deploy.log"
|
||||||
|
TIMEOUT=1800 # 30 minutes for Flutter build (increased from default)
|
||||||
|
SKIP_IF_RECENT_HOURS=2 # Skip if deployed within last 2 hours
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||||
|
|
||||||
|
# Check if we should skip deployment
|
||||||
|
LAST_DEPLOY_FILE="/tmp/last_deploy_time"
|
||||||
|
if [ -f "$LAST_DEPLOY_FILE" ]; then
|
||||||
|
LAST_DEPLOY=$(cat "$LAST_DEPLOY_FILE")
|
||||||
|
CURRENT_TIME=$(date +%s)
|
||||||
|
if [ $((CURRENT_TIME - LAST_DEPLOY)) -lt $((SKIP_IF_RECENT_HOURS * 3600)) ]; then
|
||||||
|
echo "=== Skipping deployment - recent deploy was less than $SKIP_IF_RECENT_HOURS hours ago ==="
|
||||||
|
echo "=== Auto-deploy skipped at $(date) ==="
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Scheduled auto-deploy started at $(date) ==="
|
||||||
|
|
||||||
|
# Create deploy directory if it doesn't exist
|
||||||
|
mkdir -p "$DEPLOY_DIR"
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
echo "Pulling latest changes..."
|
||||||
|
if [ -d ".git" ]; then
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main
|
||||||
|
else
|
||||||
|
git clone "$GIT_REPO" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Deploying Backend ==="
|
||||||
|
# Copy backend code to build directory
|
||||||
|
echo "Copying backend code to build directory..."
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
cp -r go_cloud/* "$BUILD_DIR/"
|
||||||
|
|
||||||
|
# Build and start backend from traefik directory
|
||||||
|
cd /opt/traefik
|
||||||
|
echo "Building go-backend container..."
|
||||||
|
docker-compose build --no-cache go-backend
|
||||||
|
echo "Recreating go-backend container with fresh environment..."
|
||||||
|
docker-compose up -d --force-recreate go-backend
|
||||||
|
|
||||||
|
echo "Backend deployed successfully!"
|
||||||
|
|
||||||
|
echo "=== Deploying Frontend ==="
|
||||||
|
# Build Flutter for web with proper timeout
|
||||||
|
echo "Building Flutter web app..."
|
||||||
|
FRONTEND_DIR="$DEPLOY_DIR/b0esche_cloud"
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
# Ensure a clean build environment to avoid stale package config or .dart_tool cache
|
||||||
|
sudo -u admin rm -rf .dart_tool build
|
||||||
|
# Fetch packages and clean previous artifacts
|
||||||
|
timeout 300 sudo -u admin /opt/flutter/bin/flutter pub get
|
||||||
|
sudo -u admin /opt/flutter/bin/flutter clean
|
||||||
|
# Now build
|
||||||
|
timeout 900 sudo -u admin /opt/flutter/bin/flutter build web --release || {
|
||||||
|
echo "Flutter build failed or timed out"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy built files to nginx volume
|
||||||
|
echo "Copying built files to web server..."
|
||||||
|
rm -rf /opt/traefik/web/*
|
||||||
|
cp -r build/web/* /opt/traefik/web/
|
||||||
|
|
||||||
|
# Restart nginx container
|
||||||
|
echo "Restarting flutter-web container..."
|
||||||
|
cd /opt/traefik
|
||||||
|
docker-compose up -d --force-recreate flutter-web
|
||||||
|
|
||||||
|
echo "=== Deployment completed successfully at $(date) ==="
|
||||||
|
|
||||||
|
# Record deployment time
|
||||||
|
date +%s > "$LAST_DEPLOY_FILE"
|
||||||
|
|
||||||
|
# Health checks
|
||||||
|
echo "=== Running health checks ==="
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
if curl -f -s https://go.b0esche.cloud/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ Backend health check passed"
|
||||||
|
else
|
||||||
|
echo "❌ Backend health check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if curl -f -s https://www.b0esche.cloud > /dev/null 2>&1; then
|
||||||
|
echo "✅ Frontend health check passed"
|
||||||
|
else
|
||||||
|
echo "❌ Frontend health check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Scheduled auto-deploy completed ==="
|
||||||
50
scripts/backup.sh
Executable file
50
scripts/backup.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# b0esche.cloud Backup Script
|
||||||
|
# Usage: ./backup.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="/opt/backups/b0esche_cloud/$DATE"
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
echo "Starting backup for b0esche.cloud - $DATE"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "Backing up Go backend PostgreSQL database..."
|
||||||
|
docker exec go-postgres pg_dump -U go_backend -Fc go_backend > "$BACKUP_DIR/go_backend.sqlc"
|
||||||
|
|
||||||
|
echo "Backing up Nextcloud database..."
|
||||||
|
docker exec nextcloud-db mysqldump -u nextcloud -pSu11Fd02!!! nextcloud > "$BACKUP_DIR/nextcloud.sql"
|
||||||
|
|
||||||
|
echo "Backing up Traefik certificates..."
|
||||||
|
cp -r /opt/traefik/acme "$BACKUP_DIR/"
|
||||||
|
|
||||||
|
echo "Backing up configuration files..."
|
||||||
|
cp /opt/go/.env.production "$BACKUP_DIR/"
|
||||||
|
cp /opt/go/docker-compose.yml "$BACKUP_DIR/go-docker-compose.yml"
|
||||||
|
cp /opt/flutter/docker-compose.yml "$BACKUP_DIR/flutter-docker-compose.yml"
|
||||||
|
cp /opt/flutter/nginx.conf "$BACKUP_DIR/"
|
||||||
|
cp /opt/traefik/docker-compose.yml "$BACKUP_DIR/traefik-docker-compose.yml"
|
||||||
|
cp /opt/traefik/traefik.yml "$BACKUP_DIR/"
|
||||||
|
|
||||||
|
echo "Backing up Docker volumes..."
|
||||||
|
docker run --rm -v nextcloud_data_31:/data -v "$BACKUP_DIR":/backup alpine tar czf /backup/nextcloud_data.tar.gz -C /data .
|
||||||
|
|
||||||
|
echo "Compressing backup..."
|
||||||
|
cd /opt/backups/b0esche_cloud
|
||||||
|
tar czf "$DATE.tar.gz" "$DATE"
|
||||||
|
rm -rf "$DATE"
|
||||||
|
|
||||||
|
echo "Backup completed: /opt/backups/b0esche_cloud/$DATE.tar.gz"
|
||||||
|
|
||||||
|
# Clean up old backups
|
||||||
|
echo "Cleaning up backups older than $RETENTION_DAYS days..."
|
||||||
|
find /opt/backups/b0esche_cloud -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
|
||||||
|
|
||||||
|
echo "Backup process completed successfully!"
|
||||||
|
|
||||||
|
# Show backup size
|
||||||
|
ls -lh "/opt/backups/b0esche_cloud/$DATE.tar.gz"
|
||||||
9
scripts/deploy-now.sh
Executable file
9
scripts/deploy-now.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Manual deploy script - call this when you want immediate deployment
|
||||||
|
# Usage: ./deploy-now.sh
|
||||||
|
|
||||||
|
echo "🚀 Starting immediate deployment..."
|
||||||
|
|
||||||
|
/opt/scripts/auto-deploy.sh
|
||||||
|
|
||||||
|
echo "✅ Manual deployment completed!"
|
||||||
120
scripts/monitor.sh
Executable file
120
scripts/monitor.sh
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# b0esche.cloud Monitoring Script
|
||||||
|
# Usage: ./monitor.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo "=== b0esche.cloud Service Status ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check critical services
|
||||||
|
services=("traefik" "go-backend" "go-postgres" "flutter-web" "nextcloud" "nextcloud-db" "collabora")
|
||||||
|
|
||||||
|
for service in "${services[@]}"; do
|
||||||
|
if docker ps --format "table {{.Names}}" | grep -q "^$service$"; then
|
||||||
|
echo -e "${GREEN}✓${NC} $service is running"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗${NC} $service is not running"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Service Health Checks ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# HTTP/HTTPS health checks
|
||||||
|
echo -n "Flutter Web (www.b0esche.cloud): "
|
||||||
|
if curl -s --max-time 5 https://www.b0esche.cloud | grep -q "b0esche_cloud"; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAILED${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "Go Backend (go.b0esche.cloud): "
|
||||||
|
if curl -s --max-time 5 https://go.b0esche.cloud/health | grep -q "ok"; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAILED${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "Nextcloud (storage.b0esche.cloud): "
|
||||||
|
if curl -s --max-time 5 -I https://storage.b0esche.cloud | grep -q "HTTP/2 200"; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAILED${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "Collabora (of.b0esche.cloud): "
|
||||||
|
if curl -s --max-time 5 -I https://of.b0esche.cloud | grep -q "HTTP/2"; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}DEGRADED${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Resource Usage ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Show container resource usage
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" | grep -E "(traefik|go-backend|flutter-web|nextcloud|collabora)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Recent Error Logs ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Show recent error logs from each service
|
||||||
|
for service in traefik go-backend nextcloud collabora; do
|
||||||
|
errors=$(docker logs "$service" --since=1h 2>&1 | grep -i error | tail -3 | wc -l)
|
||||||
|
if [ "$errors" -gt 0 ]; then
|
||||||
|
echo -e "${YELLOW}$service:${NC} $errors errors in last hour"
|
||||||
|
docker logs "$service" --since=1h 2>&1 | grep -i error | tail -3 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== SSL Certificate Status ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check certificate expiry for main domains
|
||||||
|
domains=("www.b0esche.cloud" "go.b0esche.cloud" "storage.b0esche.cloud" "of.b0esche.cloud")
|
||||||
|
|
||||||
|
for domain in "${domains[@]}"; do
|
||||||
|
expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null | grep notAfter | cut -d= -f2)
|
||||||
|
if [ -n "$expiry" ]; then
|
||||||
|
expiry_epoch=$(date -d "$expiry" +%s)
|
||||||
|
current_epoch=$(date +%s)
|
||||||
|
days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
|
||||||
|
|
||||||
|
if [ "$days_left" -lt 7 ]; then
|
||||||
|
echo -e "${RED}$domain: ${NC}Expires in $days_left days"
|
||||||
|
elif [ "$days_left" -lt 30 ]; then
|
||||||
|
echo -e "${YELLOW}$domain: ${NC}Expires in $days_left days"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}$domain: ${NC}Expires in $days_left days"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}$domain: ${NC}Certificate check failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Disk Usage ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Show disk usage for critical directories
|
||||||
|
echo "PostgreSQL data:"
|
||||||
|
du -sh /opt/go/data/postgres 2>/dev/null || echo " Not accessible"
|
||||||
|
|
||||||
|
echo "Backup directory:"
|
||||||
|
du -sh /opt/backups 2>/dev/null || echo " Not found"
|
||||||
|
|
||||||
|
echo "Docker volumes:"
|
||||||
|
docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Monitoring Complete ==="
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user