test: add Bruno API test collections for device authentication
- Device token regeneration tests (success, forbidden, not found, unauthorized) - OPDS authentication tests (Bearer token, query token) - Kobo sync tests with token authentication - Test various authentication methods and error cases
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
meta {
|
||||||
|
name: Regenerate Device Token - Forbidden
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{base_url}}/api/devices/{{other_device_id}}/regenerate-token
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Regenerate Device Token - Forbidden
|
||||||
|
|
||||||
|
Tests that users cannot regenerate tokens for devices belonging to other users.
|
||||||
|
|
||||||
|
**Expected Behavior:** Returns 403 Forbidden when trying to regenerate token for another user's device
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 403: Forbidden (device belongs to different user)
|
||||||
|
|
||||||
|
**Use Case:** Verify authorization - users can only manage their own devices
|
||||||
|
|
||||||
|
**Setup:** Use Bearer token from user A, try to regenerate token for user B's device
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
meta {
|
||||||
|
name: Regenerate Device Token - Not Found
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{base_url}}/api/devices/00000000-0000-0000-0000-000000000000/regenerate-token
|
||||||
|
body: none
|
||||||
|
auth: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Regenerate Device Token - Not Found
|
||||||
|
|
||||||
|
Tests that token regeneration returns 404 for non-existent devices.
|
||||||
|
|
||||||
|
**Expected Behavior:** Returns 404 Not Found when device UUID doesn't exist
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 404: Device not found
|
||||||
|
|
||||||
|
**Use Case:** Verify proper error handling for invalid device IDs
|
||||||
|
|
||||||
|
**Setup:** Use all-zero UUID (guaranteed to not exist in database)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
meta {
|
||||||
|
name: Regenerate Device Token - Unauthorized
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{base_url}}/api/devices/{{device_id}}/regenerate-token
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Regenerate Device Token - Unauthorized
|
||||||
|
|
||||||
|
Tests that token regeneration requires authentication.
|
||||||
|
|
||||||
|
**Expected Behavior:** Returns 401 Unauthorized when no Bearer token is provided
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 401: Unauthorized (missing or invalid token)
|
||||||
|
|
||||||
|
**Use Case:** Verify authentication is required for token regeneration
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
meta {
|
||||||
|
name: Regenerate Device Token
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{base_url}}/api/devices/{{device_id}}/regenerate-token
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Regenerate Device Token
|
||||||
|
|
||||||
|
Regenerates auth token for a device, invalidating old token immediately.
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
|
||||||
|
**Endpoint:** /api/devices/{device_id}/regenerate-token
|
||||||
|
|
||||||
|
**Authentication:** Bearer token (JWT)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `device_id` (string): Device UUID
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- `message` (string): Success message
|
||||||
|
- `auth_token` (string): New auth token
|
||||||
|
- `device` (object): Updated device details
|
||||||
|
- `sync_urls` (object): Device-specific sync URLs with new token
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Forbidden (device belongs to different user)
|
||||||
|
- 404: Device not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Old token stops working immediately
|
||||||
|
- Device must be updated with new token to resume syncing
|
||||||
|
- No data loss - device ID remains same
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Token regenerated successfully",
|
||||||
|
"auth_token": "dev_abc123...",
|
||||||
|
"device": {
|
||||||
|
"id": "uuid-here",
|
||||||
|
"device_name": "My Kobo Clara",
|
||||||
|
"device_type": "kobo",
|
||||||
|
"sync_enabled": true,
|
||||||
|
"auto_sync": true,
|
||||||
|
"sync_frequency_minutes": 5,
|
||||||
|
"created_at": "2026-02-12T10:00:00Z",
|
||||||
|
"device_metadata": "{...}"
|
||||||
|
},
|
||||||
|
"sync_urls": {
|
||||||
|
"sync_url": "http://localhost:8765/api/sync/kobo/dev_new_token",
|
||||||
|
"markup": "http://localhost:8765/api/sync/kobo/dev_new_token/markup",
|
||||||
|
"bookmark": "http://localhost:8765/api/sync/kobo/dev_new_token/bookmark",
|
||||||
|
"init": "http://localhost:8765/api/sync/kobo/dev_new_token/v1/initialization"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Old token stops working immediately
|
||||||
|
- Device must be updated with new token to resume syncing
|
||||||
|
- No data loss - device ID remains same
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
meta {
|
||||||
|
name: Download Book - Query Token
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{opds_base_url}}/opds/devices/{{device_id}}/download/{{book_id}}?token={{device_token}}
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Download Book - Query Token
|
||||||
|
|
||||||
|
Tests OPDS book download using query parameter authentication.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /opds/devices/{device_id}/download/{book_id}?token={device_token}
|
||||||
|
|
||||||
|
**Authentication:** Query parameter (for Kobo devices)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `device_id` (string): Device UUID
|
||||||
|
- `book_id` (string): Book UUID
|
||||||
|
- `token` (string): Device auth_token
|
||||||
|
|
||||||
|
**Response:** Book file (EPUB)
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success (book file)
|
||||||
|
- 401: Unauthorized (missing or invalid token)
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 404: Device not found
|
||||||
|
- 500: Book not found
|
||||||
|
|
||||||
|
**Important:** Kobo devices use query parameter for OPDS downloads
|
||||||
|
|
||||||
|
**Use Case:** Kobo devices downloading books from OPDS
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Device Catalog - Bearer
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{opds_base_url}}/opds/devices/{{device_id}}/catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Get Device OPDS Catalog - Bearer
|
||||||
|
|
||||||
|
Tests OPDS device catalog retrieval using Bearer token authentication.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /opds/devices/{device_id}/catalog
|
||||||
|
|
||||||
|
**Authentication:** Bearer token (for KOReader, API clients, other devices)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `device_id` (string): Device UUID
|
||||||
|
|
||||||
|
**Response:** OPDS Atom feed catalog
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success (OPDS catalog)
|
||||||
|
- 401: Unauthorized (missing or invalid token)
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 404: Device not found
|
||||||
|
|
||||||
|
**Important:** Bearer token in Authorization header
|
||||||
|
|
||||||
|
**Use Case:** KOReader devices, API clients, or any device configured with Bearer token
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Device Catalog - Query Token
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{opds_base_url}}/opds/devices/{{device_id}}/catalog?token={{device_token}}
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Get Device OPDS Catalog - Query Token
|
||||||
|
|
||||||
|
Tests OPDS device catalog retrieval using query parameter authentication.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /opds/devices/{device_id}/catalog?token={device_token}
|
||||||
|
|
||||||
|
**Authentication:** Query parameter (for Kobo devices)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `device_id` (string): Device UUID
|
||||||
|
- `token` (string): Device auth_token
|
||||||
|
|
||||||
|
**Response:** OPDS Atom feed catalog
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success (OPDS catalog)
|
||||||
|
- 401: Unauthorized (missing or invalid token)
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 404: Device not found
|
||||||
|
|
||||||
|
**Important:** Kobo devices use query parameter for OPDS catalog
|
||||||
|
|
||||||
|
**Use Case:** Kobo devices accessing OPDS catalog
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
meta {
|
||||||
|
name: Kobo Initialization - URL Path Token
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/v1/initialization
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Kobo Initialization - URL Path Token
|
||||||
|
|
||||||
|
Returns initialization data for Kobo device using token in URL path.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /sync/kobo/{kobo_device_token}/v1/initialization
|
||||||
|
|
||||||
|
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `kobo_device_token` (string): Device auth token from devices.auth_token
|
||||||
|
|
||||||
|
**Response:** Initialization configuration and settings
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success (initialization data)
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 404: Device not found
|
||||||
|
|
||||||
|
**Important:** Kobo devices use URL path token exclusively, never Bearer header
|
||||||
|
|
||||||
|
**Use Case:** Kobo devices initializing sync connection via stock firmware
|
||||||
|
|
||||||
|
**Note:** This endpoint is called when Kobo first connects to sync server
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Library - URL Path Token
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/library
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Get Kobo Library - URL Path Token
|
||||||
|
|
||||||
|
Retrieves library metadata for Kobo device using token in URL path.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /sync/kobo/{kobo_device_token}/library
|
||||||
|
|
||||||
|
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `kobo_device_token` (string): Device auth token from devices.auth_token
|
||||||
|
|
||||||
|
**Response:** Library metadata with book list
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success (library metadata)
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 404: Device not found
|
||||||
|
|
||||||
|
**Important:** Kobo devices use URL path token exclusively, never Bearer header
|
||||||
|
|
||||||
|
**Use Case:** Kobo devices retrieving library information via stock firmware
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
meta {
|
||||||
|
name: Sync Bookmark - URL Path Token
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/bookmark
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"ContentId": "book-uuid",
|
||||||
|
"BookmarkText": "Highlighted text",
|
||||||
|
"BookmarkType": "annotation",
|
||||||
|
"BookmarkTitle": "Chapter 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Sync Bookmark - URL Path Token
|
||||||
|
|
||||||
|
Synchronizes bookmarks and annotations from Kobo device using token in URL path.
|
||||||
|
|
||||||
|
**Method:** POST
|
||||||
|
|
||||||
|
**Endpoint:** /sync/kobo/{kobo_device_token}/bookmark
|
||||||
|
|
||||||
|
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `kobo_device_token` (string): Device auth token from devices.auth_token
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
- `ContentId` (string): Book UUID
|
||||||
|
- `BookmarkText` (string): Highlighted or annotated text
|
||||||
|
- `BookmarkType` (string): Type (annotation, bookmark, note)
|
||||||
|
- `BookmarkTitle` (string): Title for the bookmark
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
**Important:** Kobo devices use URL path token exclusively, never Bearer header
|
||||||
|
|
||||||
|
**Use Case:** Kobo devices syncing bookmarks and highlights via stock firmware
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
meta {
|
||||||
|
name: Sync Markup - URL Path Token
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/markup
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"ReadingSync": [
|
||||||
|
{
|
||||||
|
"ContentId": "book-uuid",
|
||||||
|
"PercentRead": 45.6,
|
||||||
|
"EntitlementId": "entitlement-id",
|
||||||
|
"RemainingTimeMinutes": 120,
|
||||||
|
"FirstReadTime": "2026-01-25T10:00:00Z",
|
||||||
|
"LastModified": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"BookmarkSync": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
## Sync Reading Progress - URL Path Token
|
||||||
|
|
||||||
|
Synchronizes reading progress from Kobo device using token in URL path.
|
||||||
|
|
||||||
|
**Method:** POST
|
||||||
|
|
||||||
|
**Endpoint:** /sync/kobo/{kobo_device_token}/markup
|
||||||
|
|
||||||
|
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `kobo_device_token` (string): Device auth token from devices.auth_token
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
- `ReadingSync` (array): Reading progress data
|
||||||
|
- `BookmarkSync` (array): Bookmarks and annotations
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Device sync disabled
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
**Important:** Kobo firmware requires token in URL path, cannot use Bearer header
|
||||||
|
|
||||||
|
**Use Case:** Kobo devices syncing reading progress via stock firmware
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package bookhoard/cmd/server/tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ type Querier interface {
|
|||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
|
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
||||||
// Update device catalog availability
|
// Update device catalog availability
|
||||||
UpdateDeviceCatalogAvailability(ctx context.Context, arg UpdateDeviceCatalogAvailabilityParams) error
|
UpdateDeviceCatalogAvailability(ctx context.Context, arg UpdateDeviceCatalogAvailabilityParams) error
|
||||||
// Update device file alias
|
// Update device file alias
|
||||||
|
|||||||
@@ -6573,6 +6573,42 @@ func (q *Queries) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Dev
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateDeviceAuthToken = `-- name: UpdateDeviceAuthToken :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
auth_token = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateDeviceAuthTokenParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AuthToken string `db:"auth_token" json:"auth_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateDeviceAuthToken, arg.ID, arg.AuthToken)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateDeviceCatalogAvailability = `-- name: UpdateDeviceCatalogAvailability :exec
|
const UpdateDeviceCatalogAvailability = `-- name: UpdateDeviceCatalogAvailability :exec
|
||||||
UPDATE device_catalogs
|
UPDATE device_catalogs
|
||||||
SET available = $2
|
SET available = $2
|
||||||
|
|||||||
@@ -769,6 +769,14 @@ SET
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateDeviceAuthToken :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
auth_token = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: RevokeDevice :exec
|
-- name: RevokeDevice :exec
|
||||||
UPDATE devices
|
UPDATE devices
|
||||||
SET
|
SET
|
||||||
|
|||||||
Reference in New Issue
Block a user