feat(api): /devices register + paseto v4 (middleware Bearer, bootstrap mobile du token)
This commit is contained in:
+24
-13
@@ -7,29 +7,34 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/vaultdrop/backend/config"
|
"github.com/vaultdrop/backend/config"
|
||||||
"github.com/vaultdrop/backend/handlers"
|
"github.com/vaultdrop/backend/handlers"
|
||||||
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRouter() *gin.Engine {
|
func newRouter() *gin.Engine {
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
public := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
api.GET("/health", handlers.Health)
|
public.GET("/health", handlers.Health)
|
||||||
api.POST("/devices", handlers.DevicesRegister)
|
public.POST("/devices", handlers.DevicesRegister)
|
||||||
|
}
|
||||||
|
|
||||||
api.GET("/files", handlers.FilesList)
|
protected := r.Group("/api/v1")
|
||||||
api.GET("/files/search", handlers.FilesSearch)
|
protected.Use(handlers.RequireDevice)
|
||||||
api.GET("/files/:id", handlers.FilesGet)
|
{
|
||||||
api.DELETE("/files/:id", handlers.FilesDelete)
|
protected.GET("/files", handlers.FilesList)
|
||||||
api.GET("/files/folders", handlers.FoldersList)
|
protected.GET("/files/search", handlers.FilesSearch)
|
||||||
api.POST("/files/upload", handlers.FilesUpload)
|
protected.GET("/files/:id", handlers.FilesGet)
|
||||||
|
protected.DELETE("/files/:id", handlers.FilesDelete)
|
||||||
|
protected.GET("/files/folders", handlers.FoldersList)
|
||||||
|
protected.POST("/files/upload", handlers.FilesUpload)
|
||||||
|
|
||||||
api.POST("/ocr/jobs", handlers.OcrJobsCreate)
|
protected.POST("/ocr/jobs", handlers.OcrJobsCreate)
|
||||||
api.GET("/ocr/jobs/:id", handlers.OcrJobsGet)
|
protected.GET("/ocr/jobs/:id", handlers.OcrJobsGet)
|
||||||
|
|
||||||
api.POST("/sync/ops", handlers.SyncOpsPush)
|
protected.POST("/sync/ops", handlers.SyncOpsPush)
|
||||||
api.GET("/sync/permissions", handlers.SyncPermissionsGet)
|
protected.GET("/sync/permissions", handlers.SyncPermissionsGet)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -44,6 +49,12 @@ func main() {
|
|||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authManager, err := auth.NewManager(cfg.AuthSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
handlers.Auth = authManager
|
||||||
|
|
||||||
if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil {
|
if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRouterForAuth() *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/devices", DevicesRegister)
|
||||||
|
grp := r.Group("")
|
||||||
|
grp.Use(RequireDevice)
|
||||||
|
grp.GET("/files", FilesList)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesRegisterValid(t *testing.T) {
|
||||||
|
m, _ := auth.NewManager("test-secret")
|
||||||
|
Auth = m
|
||||||
|
defer func() { Auth = nil }()
|
||||||
|
|
||||||
|
deviceID := "0123456789abcdef0123456789abcdef"
|
||||||
|
body := `{"deviceId":"` + deviceID + `"}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Data struct {
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if envelope.Data.DeviceID != deviceID {
|
||||||
|
t.Errorf("deviceId = %q", envelope.Data.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
verified, err := m.Verify(envelope.Data.Token)
|
||||||
|
if err != nil || verified != deviceID {
|
||||||
|
t.Errorf("token invalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
|
||||||
|
Auth, _ = auth.NewManager("test-secret")
|
||||||
|
defer func() { Auth = nil }()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(`{"deviceId":"UPPERCASEANDTOOLONG"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireDeviceRejectsMissingToken(t *testing.T) {
|
||||||
|
Auth, _ = auth.NewManager("test-secret")
|
||||||
|
defer func() { Auth = nil }()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
||||||
|
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireDeviceAcceptsValidToken(t *testing.T) {
|
||||||
|
Auth, _ = auth.NewManager("test-secret")
|
||||||
|
defer func() { Auth = nil }()
|
||||||
|
|
||||||
|
deviceID := "0123456789abcdef0123456789abcdef"
|
||||||
|
signed, err := Auth.Issue(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+signed)
|
||||||
|
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// FilesList is still a 501 stub — the point is it got past the middleware.
|
||||||
|
if rec.Code != 501 {
|
||||||
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,36 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/vaultdrop/backend/pkg/api"
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DevicesRegister(c *gin.Context) { api.NotImplemented(c) }
|
var deviceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||||
|
|
||||||
|
type DeviceRegisterRequest struct {
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DevicesRegister(c *gin.Context) {
|
||||||
|
|
||||||
|
var req DeviceRegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceID == "" {
|
||||||
|
api.Error(c, 400, "INVALID_REQUEST", "missing deviceId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deviceIDPattern.MatchString(req.DeviceID) {
|
||||||
|
api.Error(c, 400, "INVALID_DEVICE_ID", "deviceId must be 32 lowercase hex chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := Auth.Issue(req.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.OK(c, gin.H{"deviceId": req.DeviceID, "token": token})
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DeviceIDKey = "device_id"
|
||||||
|
|
||||||
|
// Auth issues/verifies device bearer tokens; set once at startup (cmd/server).
|
||||||
|
var Auth *auth.Manager
|
||||||
|
|
||||||
|
// RequireDevice authenticates the bearer paseto token and stores the resolved
|
||||||
|
// device_id in the gin context (cf. docs/api-v1.md §2).
|
||||||
|
func RequireDevice(c *gin.Context) {
|
||||||
|
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
token, found := strings.CutPrefix(header, "Bearer ")
|
||||||
|
|
||||||
|
if Auth == nil || !found || token == "" {
|
||||||
|
api.Error(c, 401, "UNAUTHORIZED", "missing bearer token")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID, err := Auth.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, 401, "UNAUTHORIZED", "invalid or expired token")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(DeviceIDKey, deviceID)
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"aidanwoods.dev/go-paseto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TokenTTL = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("invalid token")
|
||||||
|
|
||||||
|
// Manager issues and verifies paseto v4-local bearer tokens bound to a device_id.
|
||||||
|
type Manager struct {
|
||||||
|
key paseto.V4SymmetricKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(secret string) (*Manager, error) {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
key, err := paseto.V4SymmetricKeyFromBytes(sum[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Manager{key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Issue(deviceID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
token := paseto.NewToken()
|
||||||
|
token.SetIssuedAt(now)
|
||||||
|
token.SetNotBefore(now)
|
||||||
|
token.SetExpiration(now.Add(TokenTTL))
|
||||||
|
token.SetSubject(deviceID)
|
||||||
|
return token.V4Encrypt(m.key, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Verify(signed string) (string, error) {
|
||||||
|
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
subject, err := parsed.GetSubject()
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
return subject, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueVerifyRoundtrip(t *testing.T) {
|
||||||
|
m, err := NewManager("test-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewManager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := "0123456789abcdef0123456789abcdef"
|
||||||
|
signed, err := m.Issue(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := m.Verify(signed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
if got != deviceID {
|
||||||
|
t.Fatalf("Verify: got %q want %q", got, deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsTamperedToken(t *testing.T) {
|
||||||
|
m, err := NewManager("test-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewManager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed, _ := m.Issue("0123456789abcdef0123456789abcdef")
|
||||||
|
parts := strings.Split(signed, ".")
|
||||||
|
parts[len(parts)-1] = "nope"
|
||||||
|
tampered := strings.Join(parts, ".")
|
||||||
|
|
||||||
|
if _, err := m.Verify(tampered); err == nil {
|
||||||
|
t.Fatal("expected tampered token to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||||
|
m, err := NewManager("test-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewManager: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.Verify("not-a-token"); err == nil {
|
||||||
|
t.Fatal("expected garbage to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDifferentSecretRejectsToken(t *testing.T) {
|
||||||
|
a, _ := NewManager("secret-a")
|
||||||
|
b, _ := NewManager("secret-b")
|
||||||
|
|
||||||
|
signed, _ := a.Issue("0123456789abcdef0123456789abcdef")
|
||||||
|
if _, err := b.Verify(signed); err == nil {
|
||||||
|
t.Fatal("expected token from another manager to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -16,7 +16,8 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync)
|
|||||||
|
|
||||||
## 2. Identité et identifiants (invariants)
|
## 2. Identité et identifiants (invariants)
|
||||||
|
|
||||||
- **Device-first** : le device s'enregistre (`POST /devices`) et reçoit un token **paseto** qu'il stocke localement. Requêtes suivantes : `Authorization: Bearer <token>` ; le serveur en résout le `device_id`. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`).
|
- **Device-first** : le device s'enregistre (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) et reçoit en échange un token **paseto** v4-local qu'il stocke. Requêtes suivantes : `Authorization: Bearer <token>` (toutes les routes **sauf `/health`**), résolu en `device_id` par middleware. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`).
|
||||||
|
- La ré-émission est tolérée (le server décide de ré-énoncer un token ; la déduplication/persistance des devices arrive avec la table `devices`).
|
||||||
- **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
- **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
||||||
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync)
|
|||||||
| Méthode | Path | Requête | Réponse `data` | Statut absence |
|
| Méthode | Path | Requête | Réponse `data` | Statut absence |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
||||||
| POST | `/devices` | `{}` | `{ "deviceId": "…32-hex", "token": "paseto…" }` | — |
|
| POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex", "token": "v4.local…" }` | `INVALID_DEVICE_ID` |
|
||||||
| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — |
|
| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — |
|
||||||
| GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` |
|
| GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` |
|
||||||
| DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` |
|
| DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ApiData,
|
ApiData,
|
||||||
ApiErrorBody,
|
ApiErrorBody,
|
||||||
|
DeviceRegistration,
|
||||||
FileDto,
|
FileDto,
|
||||||
FolderDto,
|
FolderDto,
|
||||||
ListFilesParams,
|
ListFilesParams,
|
||||||
@@ -11,6 +12,12 @@ const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8
|
|||||||
|
|
||||||
export const DEFAULT_TIMEOUT_MS = 15_000;
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
let authToken: string | null = null;
|
||||||
|
|
||||||
|
export function setAuthToken(token: string | null): void {
|
||||||
|
authToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
function toQuery(params?: QueryParams): string {
|
function toQuery(params?: QueryParams): string {
|
||||||
@@ -24,6 +31,15 @@ function toQuery(params?: QueryParams): string {
|
|||||||
return query ? `?${query}` : '';
|
return query ? `?${query}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeHeaders(init?: HeadersInit): HeadersInit | undefined {
|
||||||
|
if ( !authToken ) return init;
|
||||||
|
const merged = new Headers(init);
|
||||||
|
if ( !merged.has('Authorization') ) {
|
||||||
|
merged.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
|
|
||||||
@@ -46,6 +62,7 @@ async function request<T>(
|
|||||||
try {
|
try {
|
||||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
headers: mergeHeaders(init.headers),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -76,6 +93,13 @@ export const api = {
|
|||||||
|
|
||||||
health: () => request<{ status: string }>('/health'),
|
health: () => request<{ status: string }>('/health'),
|
||||||
|
|
||||||
|
registerDevice: (deviceId: string) =>
|
||||||
|
request<DeviceRegistration>('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ deviceId }),
|
||||||
|
}),
|
||||||
|
|
||||||
listFiles: (params?: ListFilesParams) =>
|
listFiles: (params?: ListFilesParams) =>
|
||||||
request<FileDto[]>(`/files${toQuery(params)}`),
|
request<FileDto[]>(`/files${toQuery(params)}`),
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export type OcrJob = {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeviceRegistration = {
|
||||||
|
deviceId: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ListParams = {
|
export type ListParams = {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useSyncDevice } from '../features/syncDevice';
|
import { useSyncDevice } from '../features/syncDevice';
|
||||||
import { getDeviceUserId } from '../services/localStorage';
|
import { api, setAuthToken } from '../api/client';
|
||||||
|
import {
|
||||||
|
getDeviceAuthToken,
|
||||||
|
getDeviceUserId,
|
||||||
|
saveDeviceAuthToken,
|
||||||
|
} from '../services/localStorage';
|
||||||
import type { AuthContextValue, User } from './AuthContext.types';
|
import type { AuthContextValue, User } from './AuthContext.types';
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@@ -13,15 +18,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const id = await getDeviceUserId();
|
|
||||||
if (active) setDeviceUserId(id);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('device identity unavailable', error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
const bootstrap = async () => {
|
||||||
|
const id = await getDeviceUserId();
|
||||||
|
if (active) setDeviceUserId(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let token = await getDeviceAuthToken();
|
||||||
|
if ( !token ) {
|
||||||
|
const { data } = await api.registerDevice(id);
|
||||||
|
token = data.token;
|
||||||
|
await saveDeviceAuthToken(token);
|
||||||
|
}
|
||||||
|
setAuthToken(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('device registration failed (offline?)', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
useSyncDevice();
|
useSyncDevice();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Generated
+12
@@ -8,6 +8,7 @@
|
|||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"expo": "~57.0.8",
|
"expo": "~57.0.8",
|
||||||
"expo-constants": "~57.0.17",
|
"expo-constants": "~57.0.17",
|
||||||
@@ -1991,6 +1992,17 @@
|
|||||||
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo/vector-icons": {
|
||||||
|
"version": "15.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz",
|
||||||
|
"integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo-font": ">=14.0.4",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@expo/xcpretty": {
|
"node_modules/@expo/xcpretty": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"expo": "~57.0.8",
|
"expo": "~57.0.8",
|
||||||
"expo-constants": "~57.0.17",
|
"expo-constants": "~57.0.17",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export {
|
|||||||
DATABASE_NAME,
|
DATABASE_NAME,
|
||||||
DATABASE_VERSION,
|
DATABASE_VERSION,
|
||||||
DEVICE_USER_ID_KEY,
|
DEVICE_USER_ID_KEY,
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
PREFERENCES_KEY,
|
PREFERENCES_KEY,
|
||||||
PERMISSION_TTL_MS,
|
PERMISSION_TTL_MS,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@@ -11,6 +11,8 @@ export {
|
|||||||
saveUserPreferences,
|
saveUserPreferences,
|
||||||
getUserPreferences,
|
getUserPreferences,
|
||||||
getDeviceUserId,
|
getDeviceUserId,
|
||||||
|
getDeviceAuthToken,
|
||||||
|
saveDeviceAuthToken,
|
||||||
} from './preferences';
|
} from './preferences';
|
||||||
export {
|
export {
|
||||||
getResourcePermission,
|
getResourcePermission,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getSession } from '../session';
|
import { getSession } from '../session';
|
||||||
import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
|
import { AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
|
||||||
import type { UserPreferences } from '../types';
|
import type { UserPreferences } from '../types';
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
@@ -38,6 +38,26 @@ export async function saveUserPreferences(preferences: UserPreferences): Promise
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeviceAuthToken(): Promise<string | null> {
|
||||||
|
const db = await getSession();
|
||||||
|
const row = await db.getFirstAsync<{ value: string }>(
|
||||||
|
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
);
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDeviceAuthToken(token: string): Promise<void> {
|
||||||
|
const db = await getSession();
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
token,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||||
const db = await getSession();
|
const db = await getSession();
|
||||||
const row = await db.getFirstAsync<{ value: string }>(
|
const row = await db.getFirstAsync<{ value: string }>(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const PREFERENCES_KEY = 'user_preferences';
|
|||||||
|
|
||||||
export const DEVICE_USER_ID_KEY = 'device_user_id';
|
export const DEVICE_USER_ID_KEY = 'device_user_id';
|
||||||
|
|
||||||
|
export const AUTH_TOKEN_KEY = 'auth_token';
|
||||||
|
|
||||||
export const FOLDER_COLUMNS = [
|
export const FOLDER_COLUMNS = [
|
||||||
'resource_id',
|
'resource_id',
|
||||||
'uri',
|
'uri',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
getUserPreferences,
|
getUserPreferences,
|
||||||
getDeviceUserId,
|
getDeviceUserId,
|
||||||
|
getDeviceAuthToken,
|
||||||
|
saveDeviceAuthToken,
|
||||||
getFolders,
|
getFolders,
|
||||||
getFolderFolders,
|
getFolderFolders,
|
||||||
getFolder,
|
getFolder,
|
||||||
|
|||||||
Reference in New Issue
Block a user