forked from snxraven/p2ns
reorg
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
# Global Profile Plugin
|
||||
|
||||
**Status: ✅ Production Ready**
|
||||
|
||||
The Global Profile plugin provides a universal user identity system for all P2NS plugins with automatic P2P profile replication. It enables users to create, manage, and share their profiles across the entire P2NS network, with real-time updates and seamless integration with other plugins.
|
||||
|
||||
## Overview
|
||||
|
||||
Global Profile is a core identity plugin that stores user profile data in a distributed HyperDB database that automatically replicates across all connected peers. Your profile is available to all P2NS plugins, enabling a unified identity experience across the network.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- **HyperDB-Backed Storage**: All profile data is stored in HyperDB with automatic P2P replication across the network
|
||||
- **Multi-Size Avatar System**: Avatars are automatically resized and stored in multiple sizes (16px, 32px, 64px, 128px, 256px, 512px) for optimal performance
|
||||
- **Real-Time Updates**: Profile changes are broadcast in real-time via WebSocket to all connected clients and peers
|
||||
- **Extensible Field System**: Plugins can register custom fields that extend the standard profile schema
|
||||
- **Cross-Plugin Integration**: Profile data is accessible to all P2NS plugins through the unified API
|
||||
- **Web UI**: Complete web interface for managing your profile with an intuitive, modern design
|
||||
|
||||
### Profile Fields
|
||||
|
||||
The plugin supports the following standard profile fields:
|
||||
|
||||
- `peerId` - Unique identifier for the peer (required, automatically set)
|
||||
- `displayName` - User's display name
|
||||
- `bio` - Biography/about text
|
||||
- `email` - Email address
|
||||
- `website` - Personal or professional website URL
|
||||
- `xUsername` - X (Twitter) username
|
||||
- `github` - GitHub username
|
||||
- `discord` - Discord username
|
||||
- `location` - Physical location
|
||||
- `avatarHash` - Hash of the current avatar image
|
||||
- `tags` - Array of user-defined tags
|
||||
- `customFields` - Object for plugin-specific custom fields
|
||||
- `lastUpdated` - Timestamp of last profile update
|
||||
|
||||
### Profile Management Features
|
||||
|
||||
- **Search & Filter**: Search profiles by display name or bio, filter by tags
|
||||
- **Pagination**: Efficient pagination with configurable limits (1-1000 per page)
|
||||
- **Sorting**: Sort profiles by last updated (newest first) or display name (alphabetical)
|
||||
- **Online Status**: Real-time online/offline status for each peer
|
||||
- **Statistics**: Network-wide statistics including total profiles, online counts, and recent activity
|
||||
|
||||
## Architecture
|
||||
|
||||
### Storage
|
||||
|
||||
- **HyperDB**: All profile data and avatars are stored in HyperDB using base64-encoded data for avatars
|
||||
- Profile collection (`@profile/profiles`): Stores all user profile data
|
||||
- Avatar collection (`@profile/avatars`): Stores avatar images in multiple sizes
|
||||
- Fields collection (`@profile/fields`): Stores custom field definitions registered by plugins
|
||||
|
||||
### Replication
|
||||
|
||||
- **Automatic P2P Replication**: All data automatically replicates across all connected peers using the global replication topic
|
||||
- **Database Watching**: Real-time database change detection broadcasts updates immediately
|
||||
- **Peer Connection Handling**: Profile updates are triggered when peers connect or disconnect
|
||||
|
||||
### Real-Time Communication
|
||||
|
||||
- **WebSocket Server**: Provides real-time updates to connected clients
|
||||
- `profile-update`: Broadcast when a profile is created or updated
|
||||
- `profile-deleted`: Broadcast when a profile is deleted
|
||||
- `replication-status`: Periodic updates about replication status
|
||||
- **P2P Channels**: Uses P2NS channels to broadcast profile updates across the network
|
||||
|
||||
## Web Interface
|
||||
|
||||
The plugin includes a complete web interface accessible at the plugin domain root (`/`). The interface provides:
|
||||
|
||||
- Profile editing form with all standard fields
|
||||
- Avatar upload with preview
|
||||
- Tag management with inline editing
|
||||
- Real-time profile listing with search
|
||||
- Online/offline status indicators
|
||||
- Connection status monitoring
|
||||
|
||||
## API Reference
|
||||
|
||||
The plugin provides a comprehensive REST API. For interactive documentation with examples and curl commands, visit `/api/docs` when the plugin is enabled.
|
||||
|
||||
### Profile Management
|
||||
|
||||
#### Get Your Profile
|
||||
```
|
||||
GET /api/profile
|
||||
```
|
||||
Returns your local profile. Returns a default empty profile if none exists.
|
||||
|
||||
#### Update Your Profile (Full)
|
||||
```
|
||||
PUT /api/profile
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"displayName": "John Doe",
|
||||
"bio": "Software developer",
|
||||
"website": "https://example.com",
|
||||
"email": "[email protected]",
|
||||
"xUsername": "@johndoe",
|
||||
"github": "johndoe",
|
||||
"discord": "johndoe#1234",
|
||||
"location": "San Francisco, CA",
|
||||
"tags": ["developer", "opensource"],
|
||||
"customFields": {}
|
||||
}
|
||||
```
|
||||
Performs a full update of your profile. All fields must be provided.
|
||||
|
||||
#### Partially Update Your Profile
|
||||
```
|
||||
PATCH /api/profile
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"displayName": "John Doe",
|
||||
"bio": "Updated bio"
|
||||
}
|
||||
```
|
||||
Updates only the provided fields. Fields not included remain unchanged.
|
||||
|
||||
#### Get Another Peer's Profile
|
||||
```
|
||||
GET /api/profile/:peerId
|
||||
```
|
||||
Returns the profile for the specified peer ID. Returns 404 if not found.
|
||||
|
||||
#### Delete Your Profile
|
||||
```
|
||||
DELETE /api/profile
|
||||
```
|
||||
Deletes your profile and all associated avatars.
|
||||
|
||||
### Profile Listing
|
||||
|
||||
#### List All Profiles
|
||||
```
|
||||
GET /api/profiles?search=query&tag=tagName&limit=100&offset=0&sort=lastUpdated
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `search` (optional): Search term to filter by displayName or bio (case-insensitive)
|
||||
- `tag` (optional): Filter profiles that have this tag
|
||||
- `limit` (optional): Maximum number of results (1-1000, default: 100)
|
||||
- `offset` (optional): Number of results to skip for pagination (default: 0)
|
||||
- `sort` (optional): Sort field - `lastUpdated` (default, newest first) or `displayName` (alphabetical)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"peerId": "...",
|
||||
"displayName": "John Doe",
|
||||
"bio": "...",
|
||||
"online": true,
|
||||
"tags": ["developer"],
|
||||
"customFields": {},
|
||||
"lastUpdated": 1234567890
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Avatar Management
|
||||
|
||||
#### Upload Avatar
|
||||
```
|
||||
POST /api/profile/avatar
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
avatar: <image file>
|
||||
```
|
||||
Uploads an avatar image. The image is automatically resized to all supported sizes (16px, 32px, 64px, 128px, 256px, 512px) and stored in HyperDB. Requires the `sharp` library for image processing.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"avatarHash": "md5_hash_of_image"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Avatar (Default Size)
|
||||
```
|
||||
GET /api/profile/avatar/:peerId
|
||||
```
|
||||
Redirects to the 64px avatar endpoint.
|
||||
|
||||
#### Get Avatar (Specific Size)
|
||||
```
|
||||
GET /api/profile/avatar/:peerId/:size
|
||||
```
|
||||
Returns the avatar image at the specified size. Valid sizes: `16`, `32`, `64`, `128`, `256`, `512`.
|
||||
|
||||
If no avatar exists, returns a default SVG placeholder with the first character of the peer ID.
|
||||
|
||||
#### Delete Avatar
|
||||
```
|
||||
DELETE /api/profile/avatar
|
||||
```
|
||||
Deletes all avatar sizes for your profile.
|
||||
|
||||
### Custom Fields
|
||||
|
||||
#### Get All Registered Fields
|
||||
```
|
||||
GET /api/fields
|
||||
```
|
||||
Returns all custom fields registered by plugins.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"fieldKey": "favoriteColor",
|
||||
"registeredBy": "example.plugin",
|
||||
"label": "Favorite Color",
|
||||
"type": "string",
|
||||
"description": "User's favorite color",
|
||||
"defaultValue": "blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Register a Custom Field
|
||||
```
|
||||
POST /api/fields
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fieldKey": "favoriteColor",
|
||||
"label": "Favorite Color",
|
||||
"type": "string",
|
||||
"description": "User's favorite color",
|
||||
"defaultValue": "blue"
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `fieldKey`: Unique identifier for the field (string)
|
||||
- `label`: Human-readable label (string)
|
||||
- `type`: Field type - one of: `string`, `number`, `boolean`, `array`, `object`
|
||||
|
||||
**Optional Fields:**
|
||||
- `description`: Field description (string)
|
||||
- `defaultValue`: Default value (any JSON-serializable value)
|
||||
|
||||
**Note:** Custom fields are stored in the `customFields` object in the profile. Plugins should register their fields on initialization to ensure proper validation and documentation.
|
||||
|
||||
### Statistics
|
||||
|
||||
#### Get Profile Statistics
|
||||
```
|
||||
GET /api/stats
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"total": 150,
|
||||
"online": 45,
|
||||
"offline": 105,
|
||||
"recentActivity": [
|
||||
{
|
||||
"peerId": "...",
|
||||
"displayName": "John Doe",
|
||||
"hoursAgo": 2.5
|
||||
}
|
||||
],
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
Returns:
|
||||
- Total number of profiles in the network
|
||||
- Count of online vs offline peers
|
||||
- Recent activity (profiles updated in the last 24 hours, limited to top 10)
|
||||
|
||||
### Documentation
|
||||
|
||||
#### Interactive API Documentation
|
||||
```
|
||||
GET /api/docs
|
||||
```
|
||||
Returns an interactive HTML page with complete API documentation, including examples and curl commands for each endpoint.
|
||||
|
||||
## WebSocket API
|
||||
|
||||
The plugin provides WebSocket endpoints for real-time updates. Connect to the WebSocket server to receive:
|
||||
|
||||
### Message Types
|
||||
|
||||
#### `profile-update`
|
||||
Broadcast when a profile is created or updated.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "profile-update",
|
||||
"profile": {
|
||||
"peerId": "...",
|
||||
"displayName": "John Doe",
|
||||
...
|
||||
},
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
#### `profile-deleted`
|
||||
Broadcast when a profile is deleted.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "profile-deleted",
|
||||
"peerId": "...",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
#### `replication-status`
|
||||
Periodic updates about replication status (every 2 seconds when status changes).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "replication-status",
|
||||
"data": {
|
||||
"hyperdb": {
|
||||
"active": true,
|
||||
"peers": 5
|
||||
}
|
||||
},
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
### Client Messages
|
||||
|
||||
#### `request-profiles`
|
||||
Request all profiles (for backward compatibility; use REST API `/api/profiles` instead).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "request-profiles"
|
||||
}
|
||||
```
|
||||
|
||||
#### `request-replication-status`
|
||||
Request current replication status.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "request-replication-status"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin is enabled by default. Configuration is managed through `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Global Profile",
|
||||
"version": "1.1.0",
|
||||
"domain": "global.profile",
|
||||
"enabled": true,
|
||||
"description": "Universal user identity system for all P2NS plugins with P2P profile replication"
|
||||
}
|
||||
```
|
||||
|
||||
### Enabling/Disabling
|
||||
|
||||
The plugin can be enabled or disabled through:
|
||||
|
||||
1. **Admin Panel**: Navigate to the Plugins tab and toggle the Global Profile plugin status
|
||||
2. **Manual Configuration**: Edit `plugin-sites/global.profile/config.json` and change `"enabled"` to `true` or `false`, then restart P2NS
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required
|
||||
- P2NS core with HyperDB support
|
||||
- Sharp library for avatar image processing: `npm install sharp`
|
||||
|
||||
**Note:** If Sharp is not installed, avatar upload will fail with an error message. The plugin will function normally for all other features.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The plugin uses HyperDB with the following schema:
|
||||
|
||||
### Profile Schema
|
||||
- `peerId` (string, required): Unique peer identifier (primary key)
|
||||
- `displayName` (string): Display name
|
||||
- `bio` (string): Biography
|
||||
- `website` (string): Website URL
|
||||
- `email` (string): Email address
|
||||
- `xUsername` (string): X/Twitter username
|
||||
- `github` (string): GitHub username
|
||||
- `discord` (string): Discord username
|
||||
- `location` (string): Location
|
||||
- `avatarHash` (string): Avatar hash
|
||||
- `customFields` (string): JSON-encoded object for custom fields
|
||||
- `tags` (string): JSON-encoded array of tags
|
||||
- `lastUpdated` (uint): Timestamp of last update
|
||||
|
||||
### Avatar Schema
|
||||
- `peerId` (string, required): Peer identifier (part of composite key)
|
||||
- `size` (string, required): Size identifier - one of: `"16"`, `"32"`, `"64"`, `"128"`, `"256"`, `"512"`, `"original"` (part of composite key)
|
||||
- `data` (string, required): Base64-encoded image data
|
||||
|
||||
### Field Definition Schema
|
||||
- `fieldKey` (string, required): Unique field key (primary key)
|
||||
- `registeredBy` (string, required): Plugin domain that registered the field
|
||||
- `label` (string): Human-readable label
|
||||
- `type` (string): Field type
|
||||
- `description` (string): Field description
|
||||
- `defaultValue` (string): JSON-encoded default value
|
||||
|
||||
### Indexes
|
||||
|
||||
- `profiles-by-displayName`: Case-insensitive search on displayName
|
||||
|
||||
## Plugin Integration
|
||||
|
||||
Other P2NS plugins can integrate with Global Profile in several ways:
|
||||
|
||||
### Reading Profile Data
|
||||
|
||||
Plugins can fetch profile data using the REST API:
|
||||
|
||||
```javascript
|
||||
// Get a specific peer's profile
|
||||
const profile = await fetch(`https://global.profile/api/profile/${peerId}`);
|
||||
const data = await profile.json();
|
||||
|
||||
// Access standard fields
|
||||
console.log(data.displayName, data.bio, data.avatarHash);
|
||||
|
||||
// Access custom fields
|
||||
console.log(data.customFields.myCustomField);
|
||||
```
|
||||
|
||||
### Registering Custom Fields
|
||||
|
||||
Plugins can extend the profile schema by registering custom fields:
|
||||
|
||||
```javascript
|
||||
// Register a custom field on plugin initialization
|
||||
await fetch('https://global.profile/api/fields', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fieldKey: 'myCustomField',
|
||||
label: 'My Custom Field',
|
||||
type: 'string',
|
||||
description: 'Description of my custom field',
|
||||
defaultValue: 'default'
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Using Avatars
|
||||
|
||||
Avatars can be accessed via URL:
|
||||
|
||||
```html
|
||||
<!-- Default size (64px) -->
|
||||
<img src="https://global.profile/api/profile/avatar/PEER_ID">
|
||||
|
||||
<!-- Specific size -->
|
||||
<img src="https://global.profile/api/profile/avatar/PEER_ID/128">
|
||||
```
|
||||
|
||||
### WebSocket Integration
|
||||
|
||||
Plugins can connect to the WebSocket server to receive real-time profile updates:
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('wss://global.profile/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'profile-update') {
|
||||
// Handle profile update
|
||||
console.log('Profile updated:', message.profile);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Avatar Sizes**: Avatars are pre-generated in multiple sizes to avoid on-the-fly resizing
|
||||
- **Database Indexes**: Indexes on displayName and tags enable efficient searching
|
||||
- **Pagination**: Profile listings use pagination to handle large networks efficiently
|
||||
- **Caching**: Avatar endpoints include cache headers for optimal performance
|
||||
- **Base64 Storage**: Avatars are stored as base64 in HyperDB for seamless replication
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Avatar Upload Fails
|
||||
|
||||
- Ensure the `sharp` library is installed: `npm install sharp`
|
||||
- Check that the uploaded file is a valid image format
|
||||
- Verify file size is reasonable (large files may take time to process)
|
||||
|
||||
### Profile Updates Not Replicating
|
||||
|
||||
- Verify that database replication is active (check `/api/stats` or WebSocket replication-status)
|
||||
- Ensure peers are connected and replication is enabled in P2NS
|
||||
- Check logs for replication errors
|
||||
|
||||
### Database Not Ready Errors
|
||||
|
||||
- The plugin waits up to 30 seconds for database initialization on startup
|
||||
- If errors persist, restart P2NS to re-initialize the database
|
||||
- Check that HyperDB is properly configured in P2NS
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"name": "Global Profile",
|
||||
"version": "1.2.0",
|
||||
"domain": "global.profile",
|
||||
"enabled": true,
|
||||
"description": "Universal user identity system for all P2NS plugins with P2P profile replication",
|
||||
"author": "P2NS",
|
||||
"homepage": "https://github.com/p2ns/p2ns",
|
||||
"license": "MIT",
|
||||
"icon": "user",
|
||||
"dependencies": {},
|
||||
"www": "www",
|
||||
"hyperdb": {
|
||||
"schemas": {
|
||||
"namespace": "profile",
|
||||
"structs": [
|
||||
{
|
||||
"name": "profile",
|
||||
"compact": true,
|
||||
"fields": [
|
||||
{
|
||||
"name": "peerId",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "displayName",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "bio",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "website",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "xUsername",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "github",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "discord",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "avatarHash",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "customFields",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "lastUpdated",
|
||||
"type": "uint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fieldDefinition",
|
||||
"compact": true,
|
||||
"fields": [
|
||||
{
|
||||
"name": "fieldKey",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "registeredBy",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "label",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "defaultValue",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "avatar",
|
||||
"compact": true,
|
||||
"fields": [
|
||||
{
|
||||
"name": "peerId",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "data",
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"collections": [
|
||||
{
|
||||
"name": "profiles",
|
||||
"schema": "@profile/profile",
|
||||
"key": [
|
||||
"peerId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fields",
|
||||
"schema": "@profile/fieldDefinition",
|
||||
"key": [
|
||||
"fieldKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "avatars",
|
||||
"schema": "@profile/avatar",
|
||||
"key": [
|
||||
"peerId",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "profiles-by-displayName",
|
||||
"collection": "@profile/profile",
|
||||
"unique": false,
|
||||
"key": {
|
||||
"type": "string",
|
||||
"map": "mapDisplayNameToLower"
|
||||
}
|
||||
}
|
||||
],
|
||||
"helpers": "./helpers.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Global Profile Plugin - Database Operations
|
||||
*
|
||||
* Contains all database-related operations for profiles and avatars
|
||||
*/
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Avatar sizes to generate
|
||||
const AVATAR_SIZES = [16, 32, 64, 128, 256, 512];
|
||||
|
||||
// Check if sharp is available for image processing
|
||||
let sharp = null;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
sdk.log.warn('global.profile', 'sharp library not found. Avatar resizing will be disabled. Install with: npm install sharp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile from database
|
||||
*/
|
||||
async function getProfileFromDB(peerId) {
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Database ready timeout')), 5000);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
sdk.db.ready(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
const profile = await sdk.db.get('@profile/profiles', { peerId });
|
||||
return profile;
|
||||
} catch (err) {
|
||||
// Silently return null if database not initialized or timeout - this is expected during startup
|
||||
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout'))) {
|
||||
sdk.log.debug('global.profile', `Database not ready for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
sdk.log.error('global.profile', `Error getting profile for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save profile to database
|
||||
*/
|
||||
async function saveProfileToDB(profile) {
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY = 1000;
|
||||
const startTime = Date.now();
|
||||
|
||||
sdk.log.info('global.profile', `[saveProfileToDB] Starting save for peerId: ${profile.peerId?.slice(0, 16)}...`);
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] Tags: ${profile.tags}`);
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Wait for database to be ready (this will try to re-initialize if needed)
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] Attempt ${attempt}: Calling sdk.db.ready()...`);
|
||||
const readyStart = Date.now();
|
||||
await sdk.db.ready();
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] sdk.db.ready() completed in ${Date.now() - readyStart}ms`);
|
||||
|
||||
profile.lastUpdated = Date.now();
|
||||
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] Calling sdk.db.insert()...`);
|
||||
const insertStart = Date.now();
|
||||
await sdk.db.insert('@profile/profiles', profile);
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] sdk.db.insert() completed in ${Date.now() - insertStart}ms`);
|
||||
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] Calling sdk.db.flush()...`);
|
||||
const flushStart = Date.now();
|
||||
await sdk.db.flush();
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] sdk.db.flush() completed in ${Date.now() - flushStart}ms`);
|
||||
|
||||
// Success!
|
||||
sdk.log.info('global.profile', `[saveProfileToDB] Save completed successfully in ${Date.now() - startTime}ms`);
|
||||
return;
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Attempt ${attempt} failed after ${Date.now() - startTime}ms: ${err.message}`);
|
||||
|
||||
// Check if database is not initialized
|
||||
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('not available'))) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
sdk.log.warn('global.profile', `[saveProfileToDB] Database not initialized, retrying in ${RETRY_DELAY}ms... (${attempt}/${MAX_RETRIES})`);
|
||||
|
||||
// Try to re-initialize database if plugin context is available
|
||||
if (attempt === 1 || attempt % 3 === 0) {
|
||||
try {
|
||||
const pluginHandler = require('../../includes/plugins/plugin-handler');
|
||||
const plugin = pluginHandler.getPlugin('global.profile');
|
||||
if (plugin && plugin.dbConfig && !plugin.db) {
|
||||
sdk.log.info('global.profile', '[saveProfileToDB] Attempting to re-initialize database...');
|
||||
// The database should be initialized by the plugin handler
|
||||
// We just need to wait for it to be available
|
||||
}
|
||||
} catch (reinitErr) {
|
||||
sdk.log.debug('global.profile', `[saveProfileToDB] Could not check database re-initialization: ${reinitErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await sdk.utils.sleep(RETRY_DELAY);
|
||||
continue;
|
||||
} else {
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Database not initialized after ${MAX_RETRIES} retries. Profile save failed.`);
|
||||
|
||||
// Try to get more diagnostic info and potentially fix the issue
|
||||
try {
|
||||
const pluginHandler = require('../../includes/plugins/plugin-handler');
|
||||
const plugin = pluginHandler.getPlugin('global.profile');
|
||||
const dbManager = require('../../includes/plugins/db-manager');
|
||||
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Plugin exists: ${!!plugin}, Plugin has db: ${!!plugin?.db}, DB closed: ${plugin?.db?.closed}`);
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Plugin has dbConfig: ${!!plugin?.dbConfig}`);
|
||||
|
||||
// Check if database is in the instances map
|
||||
const dbInstance = dbManager.getDatabaseInstance('global.profile');
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Database in instances map: ${!!dbInstance}, closed: ${dbInstance?.closed}`);
|
||||
|
||||
// If plugin has db but it's not in the map, this is the root cause
|
||||
// The database exists but isn't registered in the instances map
|
||||
if (plugin?.db && !dbInstance && !plugin.db.closed) {
|
||||
sdk.log.error('global.profile', '[saveProfileToDB] Database exists in plugin object but not registered in databaseInstances map!');
|
||||
sdk.log.error('global.profile', '[saveProfileToDB] This indicates a bug in the database initialization. The database should be registered during plugin init.');
|
||||
// Try to manually register it (this is a workaround)
|
||||
try {
|
||||
const dbManager = require('../../includes/plugins/db-manager');
|
||||
// We can't directly access databaseInstances, but we can try to re-initialize
|
||||
// For now, just log the issue - the database should be re-initialized on next request
|
||||
} catch (registerErr) {
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Could not register database: ${registerErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (diagErr) {
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Could not get diagnostic info: ${diagErr.message}`);
|
||||
}
|
||||
|
||||
throw new Error('Database not available. Please try again in a moment.');
|
||||
}
|
||||
} else {
|
||||
// Other error, don't retry
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Error saving profile: ${err.message}`);
|
||||
sdk.log.error('global.profile', `[saveProfileToDB] Stack: ${err.stack}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all profiles from database
|
||||
*/
|
||||
async function getAllProfilesFromDB() {
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Database ready timeout')), 5000);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
sdk.db.ready(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
const profiles = await sdk.db.find('@profile/profiles', {});
|
||||
sdk.log.debug('global.profile', `Found ${profiles?.length || 0} profiles in database`);
|
||||
return profiles || [];
|
||||
} catch (err) {
|
||||
// Silently return empty array if database not initialized or timeout - this is expected during startup
|
||||
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout') || err.message.includes('not available'))) {
|
||||
sdk.log.debug('global.profile', `Database not ready when getting all profiles: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
sdk.log.error('global.profile', `Error getting all profiles: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and store avatar in multiple sizes in HyperDB
|
||||
*/
|
||||
async function processAndStoreAvatar(peerId, imageBuffer, originalFilename) {
|
||||
if (!sharp) {
|
||||
throw new Error('Avatar processing requires sharp library. Install with: npm install sharp');
|
||||
}
|
||||
|
||||
// Generate and store resized versions in HyperDB using avatar collection
|
||||
const avatarHash = crypto.createHash('md5').update(imageBuffer).digest('hex');
|
||||
|
||||
// Store original as base64 in avatar collection
|
||||
const originalBase64 = imageBuffer.toString('base64');
|
||||
await sdk.db.insert('@profile/avatars', {
|
||||
peerId: peerId,
|
||||
size: 'original',
|
||||
data: originalBase64
|
||||
});
|
||||
|
||||
// Generate and store resized versions
|
||||
for (const size of AVATAR_SIZES) {
|
||||
try {
|
||||
const resized = await sharp(imageBuffer)
|
||||
.resize(size, size, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Store as base64 in avatar collection
|
||||
const resizedBase64 = resized.toString('base64');
|
||||
await sdk.db.insert('@profile/avatars', {
|
||||
peerId: peerId,
|
||||
size: size.toString(),
|
||||
data: resizedBase64
|
||||
});
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error resizing avatar to ${size}px: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush to ensure data is persisted
|
||||
await sdk.db.flush();
|
||||
|
||||
return avatarHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar from HyperDB
|
||||
*/
|
||||
async function getAvatarFromDB(peerId, size = 64) {
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Database ready timeout')), 5000);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
sdk.db.ready(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// Try to get the size-specific avatar from HyperDB avatar collection
|
||||
const avatarRecord = await sdk.db.get('@profile/avatars', { peerId: peerId, size: size.toString() });
|
||||
if (avatarRecord && avatarRecord.data) {
|
||||
sdk.log.debug('global.profile', `Found avatar for ${peerId.slice(0, 16)} at size ${size}`);
|
||||
return Buffer.from(avatarRecord.data, 'base64');
|
||||
}
|
||||
|
||||
// If size-specific avatar not found, try to get original and resize on-the-fly if sharp is available
|
||||
const originalRecord = await sdk.db.get('@profile/avatars', { peerId: peerId, size: 'original' });
|
||||
if (originalRecord && originalRecord.data && sharp) {
|
||||
sdk.log.debug('global.profile', `Resizing original avatar for ${peerId.slice(0, 16)} to ${size}px`);
|
||||
const original = Buffer.from(originalRecord.data, 'base64');
|
||||
const resized = await sharp(original)
|
||||
.resize(size, size, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
return resized;
|
||||
}
|
||||
|
||||
// No avatar found
|
||||
return null;
|
||||
} catch (err) {
|
||||
// Silently return null if database not initialized or timeout - this is expected during startup
|
||||
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout') || err.message.includes('not available'))) {
|
||||
sdk.log.debug('global.profile', `Database not ready for avatar ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
sdk.log.error('global.profile', `Error getting avatar for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AVATAR_SIZES,
|
||||
getProfileFromDB,
|
||||
saveProfileToDB,
|
||||
getAllProfilesFromDB,
|
||||
processAndStoreAvatar,
|
||||
getAvatarFromDB
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Helper functions for Global Profile HyperDB indexes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map display name to lowercase for case-insensitive search
|
||||
* @param {Object} record - Profile record
|
||||
* @param {Object} context - Context object
|
||||
* @returns {Array<string>} Array of lowercase display names
|
||||
*/
|
||||
exports.mapDisplayNameToLower = (record, context) => {
|
||||
if (!record || !record.displayName) return [];
|
||||
const name = record.displayName.toLowerCase().trim();
|
||||
return name ? [name] : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Map tags string to array of individual tags
|
||||
* @param {Object} record - Profile record
|
||||
* @param {Object} context - Context object
|
||||
* @returns {Array<string>} Array of tag strings
|
||||
*/
|
||||
exports.mapTagsToArray = (record, context) => {
|
||||
console.log('[mapTagsToArray] Called with record.tags:', record?.tags);
|
||||
if (!record || !record.tags) {
|
||||
console.log('[mapTagsToArray] No tags, returning []');
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const tags = typeof record.tags === 'string' ? JSON.parse(record.tags) : record.tags;
|
||||
console.log('[mapTagsToArray] Parsed tags:', tags);
|
||||
if (Array.isArray(tags)) {
|
||||
const result = tags.map(tag => tag.toLowerCase().trim()).filter(tag => tag.length > 0);
|
||||
console.log('[mapTagsToArray] Returning:', result);
|
||||
return result;
|
||||
}
|
||||
console.log('[mapTagsToArray] Not an array, returning []');
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log('[mapTagsToArray] Error:', err.message);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Global Profile Plugin
|
||||
*
|
||||
* Provides universal user identity system across all P2NS plugins with:
|
||||
* - HyperDB-backed profile storage with P2P replication
|
||||
* - Multi-size avatar storage via HyperDB (base64 encoded)
|
||||
* - Extensible field system for plugin-registered custom fields
|
||||
* - Real-time profile updates via WebSocket
|
||||
*/
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
|
||||
// Import modules
|
||||
const { getProfileFromDB, saveProfileToDB } = require('./database');
|
||||
const { broadcastProfileUpdate, setupWebSocketHandlers, getReplicationStatus } = require('./websocket');
|
||||
const { setupDatabaseWatcher } = require('./watcher');
|
||||
const { handleProfileRoutes } = require('./routes/profile');
|
||||
const { handleAvatarRoutes } = require('./routes/avatar');
|
||||
const { handleProfilesRoutes } = require('./routes/profiles');
|
||||
const { handleFieldsRoutes } = require('./routes/fields');
|
||||
const { handleStatsRoutes } = require('./routes/stats');
|
||||
const { handleDocsRoutes } = require('./routes/docs');
|
||||
|
||||
/**
|
||||
* Plugin Handler
|
||||
*/
|
||||
async function handler(req, res) {
|
||||
try {
|
||||
// Handle CORS (including OPTIONS preflight)
|
||||
if (sdk.router.handleCORS(req, res, { origin: '*' })) {
|
||||
return true; // OPTIONS request handled
|
||||
}
|
||||
|
||||
const { path, query, method } = sdk.router.parseRequest(req);
|
||||
|
||||
// Handle root path - serve index.html
|
||||
if (path === '' || path === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try each route module in order
|
||||
// Avatar routes must come BEFORE profile routes to avoid conflicts with /api/profile/:peerId
|
||||
const routeModules = [
|
||||
{ name: 'avatar', handler: handleAvatarRoutes },
|
||||
{ name: 'profile', handler: handleProfileRoutes },
|
||||
{ name: 'profiles', handler: handleProfilesRoutes },
|
||||
{ name: 'fields', handler: handleFieldsRoutes },
|
||||
{ name: 'stats', handler: handleStatsRoutes },
|
||||
{ name: 'docs', handler: handleDocsRoutes }
|
||||
];
|
||||
|
||||
for (const routeModule of routeModules) {
|
||||
const result = await routeModule.handler(req, res, path, query, method);
|
||||
if (result !== false) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Return false for all other routes to allow static file serving
|
||||
return false;
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error handling request: ${err.message}`);
|
||||
return sdk.router.error(res, 'Internal Server Error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Plugin Initialization Hook
|
||||
*/
|
||||
async function onInit() {
|
||||
// Store the plugin domain globally for easy access
|
||||
global.PLUGIN_DOMAIN_GLOBAL = process.env.PLUGIN_DOMAIN || 'global.profile';
|
||||
|
||||
sdk.log.info('global.profile', 'Initializing Global Profile plugin...');
|
||||
|
||||
try {
|
||||
// Wait for database to be available with retries
|
||||
let dbReady = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await sdk.db.ready();
|
||||
if (!sdk.db.closed) {
|
||||
dbReady = true;
|
||||
sdk.log.info('global.profile', 'Database ready');
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('Database not initialized')) {
|
||||
sdk.log.debug('global.profile', `Database not ready yet, attempt ${i + 1}/30`);
|
||||
} else {
|
||||
sdk.log.warn('global.profile', `Database error on attempt ${i + 1}/30: ${err.message}`);
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
||||
}
|
||||
|
||||
if (!dbReady) {
|
||||
sdk.log.warn('global.profile', 'Database not available after 30 seconds - plugin will continue but database operations may fail');
|
||||
}
|
||||
|
||||
// Avatars are now stored in HyperDB, so no separate drive initialization needed
|
||||
sdk.log.info('global.profile', 'Avatars will be stored in HyperDB (automatic replication)');
|
||||
|
||||
// Create P2P channel for broadcasting profile updates to all peers
|
||||
try {
|
||||
sdk.channels.createChannel('profile-updates', {
|
||||
encoding: 'json',
|
||||
onMessage: (data, peerId) => {
|
||||
// Forward profile updates from peers to local WebSocket clients
|
||||
sdk.log.debug('global.profile', `Received profile update from peer ${peerId.substring(0, 16)}... via channel`);
|
||||
|
||||
// Forward to local WebSocket clients
|
||||
sdk.websocket.broadcast({
|
||||
...data,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
});
|
||||
},
|
||||
onOpen: (peerId) => {
|
||||
sdk.log.debug('global.profile', `Peer ${peerId.substring(0, 16)}... connected to profile-updates channel`);
|
||||
},
|
||||
onClose: (peerId) => {
|
||||
sdk.log.debug('global.profile', `Peer ${peerId.substring(0, 16)}... disconnected from profile-updates channel`);
|
||||
}
|
||||
});
|
||||
sdk.log.info('global.profile', 'P2P channel for profile updates created');
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error creating profile-updates channel: ${err.message}`);
|
||||
}
|
||||
|
||||
// Setup WebSocket handlers
|
||||
setupWebSocketHandlers();
|
||||
|
||||
// Setup database watcher (async, but don't wait - it will handle errors internally)
|
||||
setupDatabaseWatcher().catch(err => {
|
||||
sdk.log.error('global.profile', `Error in database watcher setup: ${err.message}`);
|
||||
});
|
||||
|
||||
// Watch for peer connections/disconnections to update online status
|
||||
sdk.events.on('peer-connected', async (data) => {
|
||||
const { getProfileFromDB } = require('./database');
|
||||
const profile = await getProfileFromDB(data.peerId);
|
||||
if (profile) {
|
||||
// Parse JSON fields for broadcast
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger active replication update when peer connects (backup mechanism)
|
||||
// The replication manager will also handle this, but this ensures updates happen
|
||||
try {
|
||||
// Give replication a moment to establish
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Check if database replication is active and trigger update
|
||||
const dbReplication = sdk.db.replication;
|
||||
if (dbReplication && dbReplication.isActive()) {
|
||||
const dbManager = require('../../includes/plugins/db-manager');
|
||||
const core = dbManager.getPluginCore('global.profile');
|
||||
if (core && !core.closed) {
|
||||
const oldLength = core.length;
|
||||
await core.update({ wait: true });
|
||||
const newLength = core.length;
|
||||
if (newLength > oldLength) {
|
||||
sdk.log.info('global.profile', `Database updated after peer ${data.peerId.slice(0, 16)} connection, length: ${oldLength} -> ${newLength}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avatars are now in HyperDB, so they replicate automatically with the database
|
||||
// No separate drive update needed
|
||||
} catch (err) {
|
||||
sdk.log.debug('global.profile', `Error triggering replication update after peer connection: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
sdk.events.on('peer-disconnected', async (data) => {
|
||||
const { getProfileFromDB } = require('./database');
|
||||
const profile = await getProfileFromDB(data.peerId);
|
||||
if (profile) {
|
||||
// Parse JSON fields for broadcast
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Verify replication is enabled
|
||||
try {
|
||||
const dbReplication = sdk.db.replication;
|
||||
if (dbReplication) {
|
||||
const dbStatus = dbReplication.getStatus();
|
||||
if (dbStatus && dbStatus.active) {
|
||||
sdk.log.info('global.profile', `Database replication active (${dbStatus.peers} peers)`);
|
||||
} else {
|
||||
sdk.log.info('global.profile', 'Database replication will be enabled automatically');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.debug('global.profile', `Could not check database replication status: ${err.message}`);
|
||||
}
|
||||
|
||||
// Replication is now handled automatically via global Corestore and replication managers
|
||||
// Active updates are triggered both by replication managers and peer connection events
|
||||
|
||||
sdk.log.info('global.profile', 'Plugin initialized successfully');
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error during initialization: ${err.message}`);
|
||||
if (err.stack) {
|
||||
sdk.log.error('global.profile', `Stack trace: ${err.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Shutdown Hook
|
||||
*/
|
||||
async function onShutdown() {
|
||||
sdk.log.info('global.profile', 'Shutting down Global Profile plugin...');
|
||||
|
||||
try {
|
||||
// Close WebSocket connections
|
||||
sdk.websocket.close();
|
||||
|
||||
sdk.log.info('global.profile', 'Plugin shutdown complete');
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error during shutdown: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the plugin interface
|
||||
module.exports = {
|
||||
handler,
|
||||
onInit,
|
||||
onShutdown
|
||||
};
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Global Profile Plugin - Avatar Routes
|
||||
*
|
||||
* Handles avatar-related API endpoints
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { getLocalPeerId, parseMultipartFormData } = require('../utils');
|
||||
const { processAndStoreAvatar, getAvatarFromDB, AVATAR_SIZES } = require('../database');
|
||||
const { broadcastProfileUpdate } = require('../websocket');
|
||||
|
||||
/**
|
||||
* Handle avatar-related routes
|
||||
*/
|
||||
async function handleAvatarRoutes(req, res, path, query, method) {
|
||||
const localPeerId = getLocalPeerId();
|
||||
|
||||
// GET /api/profile/avatar/:peerId/:size - Get avatar image
|
||||
// MUST come BEFORE the default avatar route to avoid redirect loops
|
||||
const avatarMatch = path.match(/^api\/profile\/avatar\/(.+?)\/(\d+)$/);
|
||||
if (avatarMatch && method === 'GET') {
|
||||
try {
|
||||
const peerId = decodeURIComponent(avatarMatch[1]);
|
||||
const size = parseInt(avatarMatch[2], 10);
|
||||
|
||||
if (!AVATAR_SIZES.includes(size)) {
|
||||
return sdk.router.error(res, 'Invalid avatar size', 400, req);
|
||||
}
|
||||
|
||||
sdk.log.debug('global.profile', `Requesting avatar for peer ${peerId.slice(0, 16)} at size ${size}`);
|
||||
const avatarData = await getAvatarFromDB(peerId, size);
|
||||
|
||||
if (!avatarData) {
|
||||
sdk.log.debug('global.profile', `No avatar found for peer ${peerId.slice(0, 16)}, returning default SVG`);
|
||||
// Return a default SVG avatar instead of 404
|
||||
// Use first character of peerId or a default icon
|
||||
const char = peerId && peerId.length > 0 ? peerId.charAt(0).toUpperCase() : '?';
|
||||
const fontSize = Math.floor(size * 0.4);
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" fill="#4b5563"/>
|
||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#d1d5db" font-size="${fontSize}" font-family="Arial, sans-serif" font-weight="bold">${char}</text>
|
||||
</svg>`;
|
||||
const headers = {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
};
|
||||
sdk.router.setCORS(res, { origin: '*' });
|
||||
res.writeHead(200, headers);
|
||||
res.end(svg);
|
||||
return true;
|
||||
}
|
||||
|
||||
sdk.log.debug('global.profile', `Serving avatar for peer ${peerId.slice(0, 16)} at size ${size} (${avatarData.length} bytes)`);
|
||||
const headers = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
};
|
||||
sdk.router.setCORS(res, { origin: '*' });
|
||||
res.writeHead(200, headers);
|
||||
res.end(avatarData);
|
||||
return true;
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error serving avatar: ${err.message}`);
|
||||
// Return SVG placeholder on error
|
||||
const size = parseInt(avatarMatch[2], 10) || 128;
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" fill="#4b5563"/>
|
||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#d1d5db" font-size="${Math.floor(size * 0.4)}" font-family="Arial, sans-serif" font-weight="bold">?</text>
|
||||
</svg>`;
|
||||
const headers = {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
};
|
||||
sdk.router.setCORS(res, { origin: '*' });
|
||||
res.writeHead(200, headers);
|
||||
res.end(svg);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/profile/avatar/:peerId - Get default avatar (64px)
|
||||
// MUST come AFTER /api/profile/avatar/:peerId/:size to avoid redirect loops
|
||||
// Only match paths that don't already have a size parameter
|
||||
const avatarDefaultMatch = path.match(/^api\/profile\/avatar\/(.+)$/);
|
||||
if (avatarDefaultMatch && method === 'GET') {
|
||||
// Check if this path already has a size parameter - if so, skip (should have been handled above)
|
||||
const hasSizeParam = path.match(/^api\/profile\/avatar\/(.+?)\/(\d+)$/);
|
||||
if (hasSizeParam) {
|
||||
// This should have been handled by the size-specific route above
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const peerId = decodeURIComponent(avatarDefaultMatch[1]);
|
||||
// Redirect to default size (64px)
|
||||
sdk.router.setCORS(res, { origin: '*' });
|
||||
res.writeHead(302, {
|
||||
'Location': `/api/profile/avatar/${encodeURIComponent(peerId)}/64`
|
||||
});
|
||||
res.end();
|
||||
return true;
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error redirecting avatar: ${err.message}`);
|
||||
return sdk.router.error(res, 'Invalid peer ID', 400, req);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/profile/avatar - Upload new avatar
|
||||
if (path === 'api/profile/avatar' && method === 'POST') {
|
||||
// Require authentication for write operations
|
||||
const peerId = await sdk.auth.requireLocalPeer(req, res);
|
||||
if (!peerId) return true; // 401 already sent
|
||||
|
||||
if (!localPeerId) {
|
||||
return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req);
|
||||
}
|
||||
|
||||
try {
|
||||
sdk.log.info('global.profile', 'Parsing multipart form data for avatar upload');
|
||||
sdk.log.info('global.profile', `Content-Type: ${req.headers['content-type']}`);
|
||||
sdk.log.info('global.profile', `Content-Length: ${req.headers['content-length']}`);
|
||||
|
||||
const { fields, files } = await parseMultipartFormData(req);
|
||||
|
||||
sdk.log.info('global.profile', `Parsed fields: ${Object.keys(fields).join(', ')}`);
|
||||
sdk.log.info('global.profile', `Parsed files: ${Object.keys(files).join(', ')}`);
|
||||
|
||||
const avatarFile = files.avatar;
|
||||
|
||||
if (!avatarFile) {
|
||||
sdk.log.error('global.profile', 'No avatar file in parsed files');
|
||||
sdk.log.error('global.profile', `Available files: ${Object.keys(files).join(', ')}`);
|
||||
return sdk.router.error(res, 'No avatar file provided', 400, req);
|
||||
}
|
||||
|
||||
if (!avatarFile.data || avatarFile.data.length === 0) {
|
||||
sdk.log.error('global.profile', 'Avatar file has no data');
|
||||
return sdk.router.error(res, 'Avatar file is empty', 400, req);
|
||||
}
|
||||
|
||||
sdk.log.info('global.profile', `Avatar file received: ${avatarFile.filename}, size: ${avatarFile.data.length}, type: ${avatarFile.contentType}`);
|
||||
|
||||
// Process and store avatar
|
||||
const avatarHash = await processAndStoreAvatar(localPeerId, avatarFile.data, avatarFile.filename);
|
||||
|
||||
// Update profile with avatar hash
|
||||
const { getProfileFromDB, saveProfileToDB } = require('../database');
|
||||
let profile = await getProfileFromDB(localPeerId);
|
||||
if (!profile) {
|
||||
profile = {
|
||||
peerId: localPeerId,
|
||||
displayName: '',
|
||||
bio: '',
|
||||
website: '',
|
||||
email: '',
|
||||
location: '',
|
||||
xUsername: '',
|
||||
github: '',
|
||||
discord: '',
|
||||
avatarHash: '',
|
||||
customFields: JSON.stringify({}),
|
||||
tags: JSON.stringify([]),
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
profile.avatarHash = avatarHash;
|
||||
await saveProfileToDB(profile);
|
||||
|
||||
// Parse JSON fields for broadcast
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
// Broadcast update
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
|
||||
return sdk.router.json(res, { success: true, avatarHash }, 200, req);
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error uploading avatar: ${err.message}`);
|
||||
return sdk.router.error(res, err.message, 500, req);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/profile/avatar - Delete local user's avatar
|
||||
if (path === 'api/profile/avatar' && method === 'DELETE') {
|
||||
// Require authentication for write operations
|
||||
const peerId = await sdk.auth.requireLocalPeer(req, res);
|
||||
if (!peerId) return true; // 401 already sent
|
||||
|
||||
if (!localPeerId) {
|
||||
return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req);
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete all avatar records for this peer
|
||||
await sdk.db.ready();
|
||||
const avatarRecords = await sdk.db.find('@profile/avatars', { peerId: localPeerId });
|
||||
|
||||
for (const record of avatarRecords) {
|
||||
await sdk.db.delete('@profile/avatars', { peerId: localPeerId, size: record.size });
|
||||
}
|
||||
|
||||
// Clear avatarHash from profile
|
||||
const { getProfileFromDB, saveProfileToDB } = require('../database');
|
||||
let profile = await getProfileFromDB(localPeerId);
|
||||
if (profile) {
|
||||
profile.avatarHash = '';
|
||||
await saveProfileToDB(profile);
|
||||
|
||||
// Parse JSON fields for broadcast
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
// Broadcast update
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
}
|
||||
|
||||
await sdk.db.flush();
|
||||
|
||||
return sdk.router.json(res, { success: true, message: 'Avatar deleted successfully' }, 200, req);
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error deleting avatar: ${err.message}`);
|
||||
return sdk.router.error(res, err.message, 500, req);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not handled by this module
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleAvatarRoutes
|
||||
};
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* Global Profile Plugin - Docs Routes
|
||||
*
|
||||
* Handles API documentation endpoints
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
|
||||
/**
|
||||
* Handle docs-related routes
|
||||
*/
|
||||
async function handleDocsRoutes(req, res, path, query, method) {
|
||||
// GET /api/docs - Interactive API documentation
|
||||
if (path === 'api/docs' && method === 'GET') {
|
||||
const baseUrl = `https://${process.env.PLUGIN_DOMAIN || 'global.profile'}`;
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Global Profile API Documentation</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 40px;
|
||||
}
|
||||
h1 {
|
||||
color: #2563eb;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 3px solid #2563eb;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.endpoint {
|
||||
margin-bottom: 40px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-left: 4px solid #2563eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.endpoint h2 {
|
||||
color: #1e40af;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.method.get { background: #10b981; color: white; }
|
||||
.method.post { background: #3b82f6; color: white; }
|
||||
.method.put { background: #f59e0b; color: white; }
|
||||
.method.patch { background: #8b5cf6; color: white; }
|
||||
.method.delete { background: #ef4444; color: white; }
|
||||
.path {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
color: #1e40af;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.description {
|
||||
margin: 15px 0;
|
||||
color: #555;
|
||||
}
|
||||
.section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.section h3 {
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.params {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.param {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.param-name {
|
||||
font-weight: bold;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
color: #1e40af;
|
||||
}
|
||||
.param-type {
|
||||
color: #059669;
|
||||
font-size: 0.9em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.param-desc {
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.example {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.example-title {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85em;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
code {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #dc2626;
|
||||
}
|
||||
.response {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.status-code {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.status-200 { background: #10b981; color: white; }
|
||||
.status-400 { background: #f59e0b; color: white; }
|
||||
.status-404 { background: #ef4444; color: white; }
|
||||
.status-500 { background: #dc2626; color: white; }
|
||||
.copy-btn {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.toc {
|
||||
background: #eff6ff;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.toc h2 {
|
||||
color: #1e40af;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
}
|
||||
.toc li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.toc a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
.toc a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Global Profile API Documentation</h1>
|
||||
<p class="subtitle">Complete REST API reference for the Global Profile plugin</p>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Table of Contents</h2>
|
||||
<ul>
|
||||
<li><a href="#profile">Profile Management</a></li>
|
||||
<li><a href="#profiles">Profile Listing</a></li>
|
||||
<li><a href="#avatar">Avatar Management</a></li>
|
||||
<li><a href="#fields">Custom Fields</a></li>
|
||||
<li><a href="#stats">Statistics</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<section id="profile">
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/profile</span></h2>
|
||||
<div class="description">Get your local profile. Returns default profile if none exists.</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Profile retrieved successfully</div>
|
||||
<div><span class="status-code status-500">500</span> Local peer ID not available</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET ${baseUrl}/api/profile</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Response</div>
|
||||
<div>{
|
||||
"peerId": "abc123...",
|
||||
"displayName": "John Doe",
|
||||
"bio": "Software developer",
|
||||
"website": "https://example.com",
|
||||
"email": "[email protected]",
|
||||
"xUsername": "johndoe",
|
||||
"github": "johndoe",
|
||||
"discord": "johndoe#1234",
|
||||
"location": "San Francisco, CA",
|
||||
"avatarHash": "abc123...",
|
||||
"customFields": {},
|
||||
"tags": ["developer", "opensource"],
|
||||
"lastUpdated": 1703123456789
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method put">PUT</span> <span class="path">/api/profile</span></h2>
|
||||
<div class="description">Update your profile (full update). All fields must be provided.</div>
|
||||
<div class="section">
|
||||
<h3>Request Body</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">displayName</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Display name for the profile</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">bio</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Biography or description</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">website</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Website URL</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">email</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Email address</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">xUsername</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">X (Twitter) username</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">tags</span><span class="param-type">array (optional)</span>
|
||||
<div class="param-desc">Array of tag strings</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">customFields</span><span class="param-type">object (optional)</span>
|
||||
<div class="param-desc">Custom fields object</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Profile updated successfully</div>
|
||||
<div><span class="status-code status-500">500</span> Error updating profile</div>
|
||||
<div><span class="status-code status-503">503</span> Database not ready</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X PUT ${baseUrl}/api/profile \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"displayName": "John Doe",
|
||||
"bio": "Software developer",
|
||||
"website": "https://example.com",
|
||||
"email": "[email protected]",
|
||||
"tags": ["developer"],
|
||||
"customFields": {}
|
||||
}'</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method patch">PATCH</span> <span class="path">/api/profile</span></h2>
|
||||
<div class="description">Partially update your profile. Only provided fields will be updated.</div>
|
||||
<div class="section">
|
||||
<h3>Request Body</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">Any profile field</span><span class="param-type">mixed (optional)</span>
|
||||
<div class="param-desc">Any combination of profile fields to update</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Profile updated successfully</div>
|
||||
<div><span class="status-code status-400">400</span> Invalid JSON</div>
|
||||
<div><span class="status-code status-404">404</span> Profile not found</div>
|
||||
<div><span class="status-code status-500">500</span> Error updating profile</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X PATCH ${baseUrl}/api/profile \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"displayName": "Jane Doe",
|
||||
"bio": "Updated bio"
|
||||
}'</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method delete">DELETE</span> <span class="path">/api/profile</span></h2>
|
||||
<div class="description">Delete your local profile.</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Profile deleted successfully</div>
|
||||
<div><span class="status-code status-404">404</span> Profile not found</div>
|
||||
<div><span class="status-code status-500">500</span> Error deleting profile</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X DELETE ${baseUrl}/api/profile</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/profile/:peerId</span></h2>
|
||||
<div class="description">Get a specific peer's profile by their peer ID.</div>
|
||||
<div class="section">
|
||||
<h3>Path Parameters</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">peerId</span><span class="param-type">string (required)</span>
|
||||
<div class="param-desc">The peer ID to get the profile for</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Profile retrieved successfully</div>
|
||||
<div><span class="status-code status-404">404</span> Profile not found</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET ${baseUrl}/api/profile/abc123...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="profiles">
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/profiles</span></h2>
|
||||
<div class="description">List all profiles with optional search, filtering, pagination, and sorting.</div>
|
||||
<div class="section">
|
||||
<h3>Query Parameters</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">search</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Search in displayName and bio fields</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">tag</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Filter by tag</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">limit</span><span class="param-type">number (optional, default: 100)</span>
|
||||
<div class="param-desc">Maximum number of results (1-1000)</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">offset</span><span class="param-type">number (optional, default: 0)</span>
|
||||
<div class="param-desc">Number of results to skip</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">sort</span><span class="param-type">string (optional, default: "lastUpdated")</span>
|
||||
<div class="param-desc">Sort field: "lastUpdated" or "displayName"</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Profiles retrieved successfully</div>
|
||||
<div><span class="status-code status-400">400</span> Invalid query parameters</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET "${baseUrl}/api/profiles?search=developer&limit=10&offset=0&sort=lastUpdated"</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Response</div>
|
||||
<div>{
|
||||
"profiles": [...],
|
||||
"pagination": {
|
||||
"total": 50,
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"hasMore": true
|
||||
}
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="avatar">
|
||||
<div class="endpoint">
|
||||
<h2><span class="method post">POST</span> <span class="path">/api/profile/avatar</span></h2>
|
||||
<div class="description">Upload a new avatar image. Image will be resized to multiple sizes (16, 32, 64, 128, 256, 512px).</div>
|
||||
<div class="section">
|
||||
<h3>Request Body</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">avatar</span><span class="param-type">file (required)</span>
|
||||
<div class="param-desc">Image file (multipart/form-data)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Avatar uploaded successfully</div>
|
||||
<div><span class="status-code status-400">400</span> No avatar file provided or file is empty</div>
|
||||
<div><span class="status-code status-500">500</span> Error uploading avatar</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X POST ${baseUrl}/api/profile/avatar \\
|
||||
-F "avatar=@/path/to/image.jpg"</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method delete">DELETE</span> <span class="path">/api/profile/avatar</span></h2>
|
||||
<div class="description">Delete your avatar image.</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Avatar deleted successfully</div>
|
||||
<div><span class="status-code status-500">500</span> Error deleting avatar</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X DELETE ${baseUrl}/api/profile/avatar</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/profile/avatar/:peerId</span></h2>
|
||||
<div class="description">Get default avatar (64px) for a peer. Redirects to size-specific endpoint.</div>
|
||||
<div class="section">
|
||||
<h3>Path Parameters</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">peerId</span><span class="param-type">string (required)</span>
|
||||
<div class="param-desc">The peer ID to get the avatar for</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-302">302</span> Redirects to /api/profile/avatar/:peerId/64</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET ${baseUrl}/api/profile/avatar/abc123...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/profile/avatar/:peerId/:size</span></h2>
|
||||
<div class="description">Get avatar image for a peer at a specific size. Returns default SVG if no avatar exists.</div>
|
||||
<div class="section">
|
||||
<h3>Path Parameters</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">peerId</span><span class="param-type">string (required)</span>
|
||||
<div class="param-desc">The peer ID to get the avatar for</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">size</span><span class="param-type">number (required)</span>
|
||||
<div class="param-desc">Avatar size: 16, 32, 64, 128, 256, or 512</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Avatar image (PNG or SVG)</div>
|
||||
<div><span class="status-code status-400">400</span> Invalid avatar size</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET ${baseUrl}/api/profile/avatar/abc123.../128</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="fields">
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/fields</span></h2>
|
||||
<div class="description">Get all registered custom fields.</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Fields retrieved successfully</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET ${baseUrl}/api/fields</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Response</div>
|
||||
<div>{
|
||||
"fields": [
|
||||
{
|
||||
"fieldKey": "favoriteColor",
|
||||
"registeredBy": "example.plugin",
|
||||
"label": "Favorite Color",
|
||||
"type": "string",
|
||||
"description": "User's favorite color",
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2><span class="method post">POST</span> <span class="path">/api/fields</span></h2>
|
||||
<div class="description">Register a new custom field. Fields can only be registered by the plugin that created them.</div>
|
||||
<div class="section">
|
||||
<h3>Request Body</h3>
|
||||
<div class="params">
|
||||
<div class="param">
|
||||
<span class="param-name">fieldKey</span><span class="param-type">string (required)</span>
|
||||
<div class="param-desc">Unique key for the field</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">label</span><span class="param-type">string (required)</span>
|
||||
<div class="param-desc">Human-readable label</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">type</span><span class="param-type">string (required)</span>
|
||||
<div class="param-desc">Field type (string, number, boolean, etc.)</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">description</span><span class="param-type">string (optional)</span>
|
||||
<div class="param-desc">Field description</div>
|
||||
</div>
|
||||
<div class="param">
|
||||
<span class="param-name">defaultValue</span><span class="param-type">any (optional)</span>
|
||||
<div class="param-desc">Default value for the field</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Field registered successfully</div>
|
||||
<div><span class="status-code status-400">400</span> Missing required fields or invalid JSON</div>
|
||||
<div><span class="status-code status-409">409</span> Field already registered by another plugin</div>
|
||||
<div><span class="status-code status-500">500</span> Error registering field</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X POST ${baseUrl}/api/fields \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"fieldKey": "favoriteColor",
|
||||
"label": "Favorite Color",
|
||||
"type": "string",
|
||||
"description": "User'\''s favorite color",
|
||||
"defaultValue": "blue"
|
||||
}'</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="stats">
|
||||
<div class="endpoint">
|
||||
<h2><span class="method get">GET</span> <span class="path">/api/stats</span></h2>
|
||||
<div class="description">Get profile statistics including total counts, online/offline status, and recent activity.</div>
|
||||
<div class="response">
|
||||
<div><span class="status-code status-200">200 OK</span> Statistics retrieved successfully</div>
|
||||
<div><span class="status-code status-500">500</span> Error getting stats</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Request</div>
|
||||
<div>curl -X GET ${baseUrl}/api/stats</div>
|
||||
</div>
|
||||
<div class="example">
|
||||
<div class="example-title">Example Response</div>
|
||||
<div>{
|
||||
"total": 50,
|
||||
"online": 12,
|
||||
"offline": 38,
|
||||
"recentActivity": [
|
||||
{
|
||||
"peerId": "abc123...",
|
||||
"displayName": "John Doe",
|
||||
"hoursAgo": 2.5
|
||||
}
|
||||
],
|
||||
"timestamp": 1703123456789
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; color: #666; font-size: 0.9em;">
|
||||
<p><strong>Base URL:</strong> <code>${baseUrl}</code></p>
|
||||
<p><strong>Note:</strong> All endpoints return JSON unless otherwise specified. Error responses include an <code>error</code> field with a descriptive message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simple copy functionality for code blocks
|
||||
document.querySelectorAll('.example').forEach(block => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy-btn';
|
||||
btn.textContent = 'Copy';
|
||||
btn.onclick = () => {
|
||||
const text = block.querySelector('div:last-child').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 2000);
|
||||
});
|
||||
};
|
||||
block.appendChild(btn);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
});
|
||||
res.end(html);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // Not handled by this module
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleDocsRoutes
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Global Profile Plugin - Fields Routes
|
||||
*
|
||||
* Handles custom fields management API endpoints
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
|
||||
/**
|
||||
* Handle fields-related routes
|
||||
*/
|
||||
async function handleFieldsRoutes(req, res, path, query, method) {
|
||||
// GET /api/fields - Get all registered fields
|
||||
if (path === 'api/fields' && method === 'GET') {
|
||||
try {
|
||||
await sdk.db.ready();
|
||||
const fields = await sdk.db.find('@profile/fields', {});
|
||||
return sdk.router.json(res, { fields: fields || [] });
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting fields: ${err.message}`);
|
||||
if (err.message && err.message.includes('Database not initialized')) {
|
||||
return sdk.router.json(res, { fields: [], error: 'Database not ready' }, 503);
|
||||
}
|
||||
return sdk.router.error(res, 'Failed to retrieve fields', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/fields - Register a new custom field
|
||||
if (path === 'api/fields' && method === 'POST') {
|
||||
// Require authentication for write operations
|
||||
const peerId = await sdk.auth.requireLocalPeer(req, res);
|
||||
if (!peerId) return true; // 401 already sent
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
if (!body) {
|
||||
return sdk.router.error(res, 'Request body is required', 400);
|
||||
}
|
||||
|
||||
let fieldData;
|
||||
try {
|
||||
fieldData = JSON.parse(body);
|
||||
} catch (parseErr) {
|
||||
return sdk.router.error(res, 'Invalid JSON in request body', 400);
|
||||
}
|
||||
|
||||
const pluginDomain = process.env.PLUGIN_DOMAIN || 'unknown';
|
||||
|
||||
if (!fieldData.fieldKey || !fieldData.label || !fieldData.type) {
|
||||
return sdk.router.error(res, 'Missing required fields: fieldKey, label, type', 400);
|
||||
}
|
||||
|
||||
// Validate fieldKey format
|
||||
if (typeof fieldData.fieldKey !== 'string' || fieldData.fieldKey.trim() === '') {
|
||||
return sdk.router.error(res, 'fieldKey must be a non-empty string', 400);
|
||||
}
|
||||
|
||||
// Validate type
|
||||
const validTypes = ['string', 'number', 'boolean', 'array', 'object'];
|
||||
if (!validTypes.includes(fieldData.type)) {
|
||||
return sdk.router.error(res, `type must be one of: ${validTypes.join(', ')}`, 400);
|
||||
}
|
||||
|
||||
await sdk.db.ready();
|
||||
|
||||
// Check if field already exists
|
||||
const existing = await sdk.db.get('@profile/fields', { fieldKey: fieldData.fieldKey });
|
||||
if (existing && existing.registeredBy !== pluginDomain) {
|
||||
return sdk.router.error(res, 'Field already registered by another plugin', 409);
|
||||
}
|
||||
|
||||
const fieldDef = {
|
||||
fieldKey: fieldData.fieldKey,
|
||||
registeredBy: pluginDomain,
|
||||
label: fieldData.label,
|
||||
type: fieldData.type,
|
||||
description: fieldData.description || '',
|
||||
defaultValue: fieldData.defaultValue ? JSON.stringify(fieldData.defaultValue) : ''
|
||||
};
|
||||
|
||||
await sdk.db.insert('@profile/fields', fieldDef);
|
||||
await sdk.db.flush();
|
||||
|
||||
return sdk.router.json(res, { success: true, field: fieldDef });
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error registering field: ${err.message}`);
|
||||
|
||||
if (err.message && err.message.includes('Unexpected token')) {
|
||||
return sdk.router.error(res, 'Invalid JSON in request body', 400);
|
||||
}
|
||||
|
||||
return sdk.router.error(res, err.message, 500);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // Not handled by this module
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleFieldsRoutes
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Global Profile Plugin - Profile Routes
|
||||
*
|
||||
* Handles profile-related API endpoints
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { getLocalPeerId } = require('../utils');
|
||||
const { getProfileFromDB, saveProfileToDB } = require('../database');
|
||||
const { broadcastProfileUpdate } = require('../websocket');
|
||||
|
||||
/**
|
||||
* Helper to call router methods with CORS support
|
||||
*/
|
||||
const router = {
|
||||
json: (res, data, status, req) => sdk.router.json(res, data, status, req),
|
||||
error: (res, message, status, req) => sdk.router.error(res, message, status, req),
|
||||
notFound: (res, message, req) => sdk.router.notFound(res, message, req)
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle profile-related routes
|
||||
*/
|
||||
async function handleProfileRoutes(req, res, path, query, method) {
|
||||
const localPeerId = getLocalPeerId();
|
||||
|
||||
// GET /api/profile - Get local user's profile
|
||||
if (path === 'api/profile' && method === 'GET') {
|
||||
let requestTimeout = null;
|
||||
try {
|
||||
if (!localPeerId) {
|
||||
return router.json(res, { error: 'Local peer ID not available' }, 500, req);
|
||||
}
|
||||
|
||||
// Add overall timeout for the entire request
|
||||
requestTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
sdk.log.error('global.profile', 'GET /api/profile request timeout');
|
||||
try {
|
||||
router.error(res, 'Request timeout - database may not be ready', 503, req);
|
||||
} catch (e) {
|
||||
// Ignore if response already sent
|
||||
}
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
const profile = await getProfileFromDB(localPeerId);
|
||||
|
||||
if (requestTimeout) {
|
||||
clearTimeout(requestTimeout);
|
||||
requestTimeout = null;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
// Return default profile
|
||||
return router.json(res, {
|
||||
peerId: localPeerId,
|
||||
displayName: '',
|
||||
bio: '',
|
||||
website: '',
|
||||
email: '',
|
||||
xUsername: '',
|
||||
avatarHash: '',
|
||||
customFields: {},
|
||||
tags: [],
|
||||
lastUpdated: null
|
||||
}, 200, req);
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
return router.json(res, profile, 200, req);
|
||||
} catch (err) {
|
||||
if (requestTimeout) {
|
||||
clearTimeout(requestTimeout);
|
||||
}
|
||||
sdk.log.error('global.profile', `Error getting profile: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
return router.error(res, 'Failed to retrieve profile', 500, req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/profile - Update local user's profile
|
||||
if (path === 'api/profile' && method === 'PUT') {
|
||||
sdk.log.info('global.profile', '[PUT /api/profile] Request received');
|
||||
const startTime = Date.now();
|
||||
|
||||
// Require authentication for write operations
|
||||
sdk.log.debug('global.profile', '[PUT /api/profile] Checking authentication...');
|
||||
const peerId = await sdk.auth.requireLocalPeer(req, res);
|
||||
if (!peerId) {
|
||||
sdk.log.warn('global.profile', '[PUT /api/profile] Authentication failed');
|
||||
return true; // 401 already sent
|
||||
}
|
||||
sdk.log.debug('global.profile', `[PUT /api/profile] Authenticated as ${peerId.slice(0, 16)}...`);
|
||||
|
||||
if (!localPeerId) {
|
||||
sdk.log.error('global.profile', '[PUT /api/profile] Local peer ID not available');
|
||||
return router.json(res, { error: 'Local peer ID not available' }, 500, req);
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
sdk.log.debug('global.profile', `[PUT /api/profile] Body received (${body.length} bytes)`);
|
||||
|
||||
if (!body) {
|
||||
sdk.log.warn('global.profile', '[PUT /api/profile] Empty request body');
|
||||
return sdk.router.error(res, 'Request body is required', 400, req);
|
||||
}
|
||||
|
||||
let updates;
|
||||
try {
|
||||
updates = JSON.parse(body);
|
||||
sdk.log.debug('global.profile', `[PUT /api/profile] Parsed updates: tags=${JSON.stringify(updates.tags)}`);
|
||||
} catch (parseErr) {
|
||||
sdk.log.error('global.profile', `[PUT /api/profile] JSON parse error: ${parseErr.message}`);
|
||||
return sdk.router.error(res, 'Invalid JSON in request body', 400, req);
|
||||
}
|
||||
|
||||
// Get existing profile or create new (with retry for DB initialization)
|
||||
sdk.log.debug('global.profile', '[PUT /api/profile] Getting existing profile from DB...');
|
||||
const getProfileStart = Date.now();
|
||||
let profile = null;
|
||||
const MAX_RETRIES = 10;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
profile = await getProfileFromDB(localPeerId);
|
||||
sdk.log.debug('global.profile', `[PUT /api/profile] Got profile in ${Date.now() - getProfileStart}ms`);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('Database not initialized') && attempt < MAX_RETRIES) {
|
||||
sdk.log.warn('global.profile', `[PUT /api/profile] Database not initialized when getting profile, retrying... (${attempt}/${MAX_RETRIES})`);
|
||||
await sdk.utils.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
// If it's not a DB init error, or we've exhausted retries, throw
|
||||
if (!err.message || !err.message.includes('Database not initialized')) {
|
||||
throw err;
|
||||
}
|
||||
// If we've exhausted retries, profile will be null and we'll create a new one
|
||||
sdk.log.warn('global.profile', '[PUT /api/profile] Exhausted retries, creating new profile');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!profile) {
|
||||
sdk.log.info('global.profile', '[PUT /api/profile] Creating new profile');
|
||||
profile = {
|
||||
peerId: localPeerId,
|
||||
displayName: '',
|
||||
bio: '',
|
||||
website: '',
|
||||
email: '',
|
||||
xUsername: '',
|
||||
avatarHash: '',
|
||||
customFields: JSON.stringify({}),
|
||||
tags: JSON.stringify([]),
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// Update fields
|
||||
sdk.log.debug('global.profile', '[PUT /api/profile] Updating profile fields...');
|
||||
if (updates.displayName !== undefined) profile.displayName = updates.displayName;
|
||||
if (updates.bio !== undefined) profile.bio = updates.bio;
|
||||
if (updates.website !== undefined) profile.website = updates.website;
|
||||
if (updates.email !== undefined) profile.email = updates.email;
|
||||
if (updates.xUsername !== undefined) profile.xUsername = updates.xUsername;
|
||||
if (updates.tags !== undefined) {
|
||||
const tagsArray = Array.isArray(updates.tags) ? updates.tags : [];
|
||||
sdk.log.debug('global.profile', `[PUT /api/profile] Setting tags: ${JSON.stringify(tagsArray)}`);
|
||||
profile.tags = JSON.stringify(tagsArray);
|
||||
}
|
||||
if (updates.customFields !== undefined) {
|
||||
profile.customFields = JSON.stringify(updates.customFields);
|
||||
}
|
||||
|
||||
sdk.log.info('global.profile', '[PUT /api/profile] Saving profile to DB...');
|
||||
const saveStart = Date.now();
|
||||
await saveProfileToDB(profile);
|
||||
sdk.log.info('global.profile', `[PUT /api/profile] Profile saved in ${Date.now() - saveStart}ms`);
|
||||
|
||||
// Parse JSON fields for response
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
// Broadcast update
|
||||
sdk.log.debug('global.profile', '[PUT /api/profile] Broadcasting update...');
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
|
||||
sdk.log.info('global.profile', `[PUT /api/profile] Request completed in ${Date.now() - startTime}ms`);
|
||||
return sdk.router.json(res, { success: true, profile }, 200, req);
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `[PUT /api/profile] Error after ${Date.now() - startTime}ms: ${err.message}`);
|
||||
sdk.log.error('global.profile', `[PUT /api/profile] Stack: ${err.stack}`);
|
||||
|
||||
// Provide user-friendly error message
|
||||
let errorMessage = err.message;
|
||||
if (errorMessage.includes('Database not available') || errorMessage.includes('Database not initialized')) {
|
||||
errorMessage = 'Database is not ready yet. Please wait a moment and try again.';
|
||||
return router.error(res, errorMessage, 503, req); // 503 Service Unavailable
|
||||
}
|
||||
|
||||
return router.error(res, errorMessage, 500, req);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// PATCH /api/profile - Partial update local user's profile
|
||||
if (path === 'api/profile' && method === 'PATCH') {
|
||||
// Require authentication for write operations
|
||||
const peerId = await sdk.auth.requireLocalPeer(req, res);
|
||||
if (!peerId) return true; // 401 already sent
|
||||
|
||||
if (!localPeerId) {
|
||||
return router.json(res, { error: 'Local peer ID not available' }, 500, req);
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
if (!body) {
|
||||
return sdk.router.error(res, 'Request body is required', 400, req);
|
||||
}
|
||||
|
||||
let updates;
|
||||
try {
|
||||
updates = JSON.parse(body);
|
||||
} catch (parseErr) {
|
||||
return sdk.router.error(res, 'Invalid JSON in request body', 400, req);
|
||||
}
|
||||
|
||||
// Get existing profile
|
||||
let profile = await getProfileFromDB(localPeerId);
|
||||
if (!profile) {
|
||||
return sdk.router.json(res, { error: 'Profile not found' }, 404, req);
|
||||
}
|
||||
|
||||
// Update only provided fields
|
||||
if (updates.displayName !== undefined) profile.displayName = updates.displayName;
|
||||
if (updates.bio !== undefined) profile.bio = updates.bio;
|
||||
if (updates.website !== undefined) profile.website = updates.website;
|
||||
if (updates.email !== undefined) profile.email = updates.email;
|
||||
if (updates.xUsername !== undefined) profile.xUsername = updates.xUsername;
|
||||
if (updates.github !== undefined) profile.github = updates.github;
|
||||
if (updates.discord !== undefined) profile.discord = updates.discord;
|
||||
if (updates.location !== undefined) profile.location = updates.location;
|
||||
if (updates.tags !== undefined) {
|
||||
profile.tags = JSON.stringify(Array.isArray(updates.tags) ? updates.tags : []);
|
||||
}
|
||||
if (updates.customFields !== undefined) {
|
||||
profile.customFields = JSON.stringify(updates.customFields);
|
||||
}
|
||||
|
||||
await saveProfileToDB(profile);
|
||||
|
||||
// Parse JSON fields for response
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
// Broadcast update
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
|
||||
return sdk.router.json(res, { success: true, profile }, 200, req);
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error patching profile: ${err.message}`);
|
||||
|
||||
if (err.message && err.message.includes('Unexpected token')) {
|
||||
return sdk.router.error(res, 'Invalid JSON in request body', 400, req);
|
||||
}
|
||||
|
||||
let errorMessage = err.message;
|
||||
if (errorMessage.includes('Database not available') || errorMessage.includes('Database not initialized')) {
|
||||
errorMessage = 'Database is not ready yet. Please wait a moment and try again.';
|
||||
return sdk.router.error(res, errorMessage, 503, req);
|
||||
}
|
||||
|
||||
return sdk.router.error(res, errorMessage, 500, req);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/profile/:peerId - Get specific peer's profile
|
||||
// MUST come after /api/profile/avatar/:peerId/:size to avoid route conflicts
|
||||
const profileMatch = path.match(/^api\/profile\/(.+)$/);
|
||||
if (profileMatch && method === 'GET') {
|
||||
try {
|
||||
let peerId = profileMatch[1];
|
||||
|
||||
// Remove trailing slash if present
|
||||
if (peerId.endsWith('/')) {
|
||||
peerId = peerId.slice(0, -1);
|
||||
}
|
||||
|
||||
peerId = decodeURIComponent(peerId);
|
||||
|
||||
if (!peerId || peerId.trim() === '') {
|
||||
return sdk.router.error(res, 'Invalid peer ID', 400, req);
|
||||
}
|
||||
|
||||
sdk.log.debug('global.profile', `Fetching profile for peer: ${peerId.slice(0, 16)}...`);
|
||||
const profile = await getProfileFromDB(peerId);
|
||||
|
||||
if (!profile) {
|
||||
sdk.log.debug('global.profile', `Profile not found for peer: ${peerId.slice(0, 16)}...`);
|
||||
// Return a default profile structure instead of 404, so the modal can still display
|
||||
// This is useful when the profile hasn't been replicated yet
|
||||
return sdk.router.json(res, {
|
||||
peerId: peerId,
|
||||
displayName: '',
|
||||
bio: '',
|
||||
website: '',
|
||||
email: '',
|
||||
xUsername: '',
|
||||
avatarHash: '',
|
||||
customFields: {},
|
||||
tags: [],
|
||||
lastUpdated: null
|
||||
}, 200, req);
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
return sdk.router.json(res, profile, 200, req);
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting peer profile: ${err.message}`);
|
||||
return sdk.router.error(res, 'Failed to retrieve profile', 500, req);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/profile - Delete local user's profile
|
||||
if (path === 'api/profile' && method === 'DELETE') {
|
||||
// Require authentication for write operations
|
||||
const peerId = await sdk.auth.requireLocalPeer(req, res);
|
||||
if (!peerId) return true; // 401 already sent
|
||||
|
||||
if (!localPeerId) {
|
||||
return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req);
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await getProfileFromDB(localPeerId);
|
||||
if (!profile) {
|
||||
return sdk.router.json(res, { error: 'Profile not found' }, 404, req);
|
||||
}
|
||||
|
||||
// Delete all avatars for this profile
|
||||
try {
|
||||
const avatarSizes = [64, 128, 256, 512];
|
||||
for (const size of avatarSizes) {
|
||||
await sdk.db.delete('@profile/avatars', { peerId: localPeerId, size });
|
||||
}
|
||||
sdk.log.debug('global.profile', `Deleted all avatars for ${localPeerId.slice(0, 16)}...`);
|
||||
} catch (avatarErr) {
|
||||
sdk.log.warn('global.profile', `Error deleting avatars: ${avatarErr.message}`);
|
||||
}
|
||||
|
||||
// Delete profile from database
|
||||
await sdk.db.delete('@profile/profiles', { peerId: localPeerId });
|
||||
|
||||
// Broadcast deletion
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-deleted',
|
||||
peerId: localPeerId
|
||||
});
|
||||
|
||||
return sdk.router.json(res, { success: true, message: 'Profile deleted successfully' }, 200, req);
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error deleting profile: ${err.message}`);
|
||||
return sdk.router.error(res, err.message, 500, req);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not handled by this module
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleProfileRoutes
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Global Profile Plugin - Profiles Routes
|
||||
*
|
||||
* Handles profiles listing and search API endpoints
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { getAllProfilesFromDB } = require('../database');
|
||||
|
||||
/**
|
||||
* Handle profiles-related routes
|
||||
*/
|
||||
async function handleProfilesRoutes(req, res, path, query, method) {
|
||||
// GET /api/profiles - List all profiles with search/filter, pagination, and sorting
|
||||
if (path === 'api/profiles' && method === 'GET') {
|
||||
try {
|
||||
const search = query.search || '';
|
||||
const tagFilter = query.tag || '';
|
||||
const limit = parseInt(query.limit || '100', 10);
|
||||
const offset = parseInt(query.offset || '0', 10);
|
||||
const sort = query.sort || 'lastUpdated'; // lastUpdated, displayName
|
||||
|
||||
// Validate pagination params
|
||||
if (limit < 1 || limit > 1000) {
|
||||
return sdk.router.error(res, 'Limit must be between 1 and 1000', 400);
|
||||
}
|
||||
if (offset < 0) {
|
||||
return sdk.router.error(res, 'Offset must be >= 0', 400);
|
||||
}
|
||||
if (sort !== 'lastUpdated' && sort !== 'displayName') {
|
||||
return sdk.router.error(res, 'Sort must be either "lastUpdated" or "displayName"', 400);
|
||||
}
|
||||
|
||||
const allProfiles = await getAllProfilesFromDB();
|
||||
let filtered = allProfiles;
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
filtered = filtered.filter(p =>
|
||||
(p.displayName && p.displayName.toLowerCase().includes(searchLower)) ||
|
||||
(p.bio && p.bio.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply tag filter
|
||||
if (tagFilter) {
|
||||
filtered = filtered.filter(p => {
|
||||
try {
|
||||
const tags = p.tags ? JSON.parse(p.tags) : [];
|
||||
return tags.some(tag => tag.toLowerCase() === tagFilter.toLowerCase());
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sort === 'lastUpdated') {
|
||||
filtered.sort((a, b) => {
|
||||
const aTime = a.lastUpdated || 0;
|
||||
const bTime = b.lastUpdated || 0;
|
||||
return bTime - aTime; // Descending (newest first)
|
||||
});
|
||||
} else if (sort === 'displayName') {
|
||||
filtered.sort((a, b) => {
|
||||
const aName = (a.displayName || '').toLowerCase();
|
||||
const bName = (b.displayName || '').toLowerCase();
|
||||
return aName.localeCompare(bName); // Ascending (A-Z)
|
||||
});
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
// Paginate
|
||||
const paginated = filtered.slice(offset, offset + limit);
|
||||
|
||||
// Parse JSON fields and add online status
|
||||
const profiles = paginated.map(p => {
|
||||
try {
|
||||
p.customFields = p.customFields ? JSON.parse(p.customFields) : {};
|
||||
p.tags = p.tags ? JSON.parse(p.tags) : [];
|
||||
} catch (err) {
|
||||
p.customFields = {};
|
||||
p.tags = [];
|
||||
}
|
||||
|
||||
// Add online status
|
||||
p.online = sdk.state.peerIds.includes(p.peerId);
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
return sdk.router.json(res, {
|
||||
profiles,
|
||||
pagination: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting profiles: ${err.message}`);
|
||||
return sdk.router.error(res, err.message, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not handled by this module
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleProfilesRoutes
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Global Profile Plugin - Stats Routes
|
||||
*
|
||||
* Handles statistics API endpoints
|
||||
*/
|
||||
|
||||
const sdk = require('../../../includes/plugins/sdk');
|
||||
const { getAllProfilesFromDB } = require('../database');
|
||||
|
||||
/**
|
||||
* Handle stats-related routes
|
||||
*/
|
||||
async function handleStatsRoutes(req, res, path, query, method) {
|
||||
// GET /api/stats - Get profile statistics
|
||||
if (path === 'api/stats' && method === 'GET') {
|
||||
try {
|
||||
const allProfiles = await getAllProfilesFromDB();
|
||||
const onlinePeerIds = sdk.state.peerIds || [];
|
||||
|
||||
// Count online/offline profiles
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
const recentActivity = [];
|
||||
|
||||
for (const profile of allProfiles) {
|
||||
const isOnline = onlinePeerIds.includes(profile.peerId);
|
||||
if (isOnline) {
|
||||
onlineCount++;
|
||||
} else {
|
||||
offlineCount++;
|
||||
}
|
||||
|
||||
// Track recent activity (last 24 hours)
|
||||
if (profile.lastUpdated) {
|
||||
const hoursAgo = (Date.now() - profile.lastUpdated) / (1000 * 60 * 60);
|
||||
if (hoursAgo <= 24) {
|
||||
recentActivity.push({
|
||||
peerId: profile.peerId,
|
||||
displayName: profile.displayName || 'Unknown',
|
||||
hoursAgo: Math.round(hoursAgo * 10) / 10
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort recent activity by most recent
|
||||
recentActivity.sort((a, b) => {
|
||||
const aProfile = allProfiles.find(p => p.peerId === a.peerId);
|
||||
const bProfile = allProfiles.find(p => p.peerId === b.peerId);
|
||||
return (bProfile?.lastUpdated || 0) - (aProfile?.lastUpdated || 0);
|
||||
});
|
||||
|
||||
return sdk.router.json(res, {
|
||||
total: allProfiles.length,
|
||||
online: onlineCount,
|
||||
offline: offlineCount,
|
||||
recentActivity: recentActivity.slice(0, 10), // Top 10 most recent
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting stats: ${err.message}`);
|
||||
return sdk.router.error(res, err.message, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not handled by this module
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleStatsRoutes
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Global Profile Plugin - Utility Functions
|
||||
*
|
||||
* Contains utility functions used across the plugin
|
||||
*/
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
|
||||
/**
|
||||
* Get local peer ID
|
||||
*/
|
||||
function getLocalPeerId() {
|
||||
return sdk.state.localPeerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is available
|
||||
*/
|
||||
async function isDatabaseAvailable() {
|
||||
try {
|
||||
await sdk.db.ready();
|
||||
return !sdk.db.closed;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multipart form data (simple implementation)
|
||||
*/
|
||||
function parseMultipartFormData(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = Buffer.alloc(0);
|
||||
|
||||
req.on('data', (chunk) => {
|
||||
body = Buffer.concat([body, chunk]);
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
try {
|
||||
// Simple multipart parsing (for avatar uploads)
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||
if (!boundaryMatch) {
|
||||
sdk.log.error('global.profile', 'No boundary found in Content-Type header');
|
||||
return reject(new Error('Invalid multipart form data: no boundary'));
|
||||
}
|
||||
|
||||
const boundary = boundaryMatch[1].trim();
|
||||
sdk.log.info('global.profile', `Parsing multipart with boundary: ${boundary}`);
|
||||
|
||||
// Split by boundary, but keep binary data intact
|
||||
const boundaryBuffer = Buffer.from(`--${boundary}`, 'utf-8');
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
|
||||
while (true) {
|
||||
const index = body.indexOf(boundaryBuffer, start);
|
||||
if (index === -1) break;
|
||||
if (start < index) {
|
||||
parts.push(body.slice(start, index));
|
||||
}
|
||||
start = index + boundaryBuffer.length;
|
||||
}
|
||||
if (start < body.length) {
|
||||
parts.push(body.slice(start));
|
||||
}
|
||||
|
||||
const fields = {};
|
||||
const files = {};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.length === 0) continue;
|
||||
|
||||
// Find the header/content separator
|
||||
const separator = Buffer.from('\r\n\r\n', 'utf-8');
|
||||
const separatorIndex = part.indexOf(separator);
|
||||
if (separatorIndex === -1) continue;
|
||||
|
||||
const headerBuffer = part.slice(0, separatorIndex);
|
||||
const contentBuffer = part.slice(separatorIndex + separator.length);
|
||||
|
||||
// Remove trailing boundary markers and whitespace
|
||||
const headerText = headerBuffer.toString('utf-8').trim();
|
||||
if (!headerText || headerText === '--') continue;
|
||||
|
||||
if (headerText.includes('Content-Disposition')) {
|
||||
const nameMatch = headerText.match(/name="([^"]+)"/);
|
||||
const filenameMatch = headerText.match(/filename="([^"]+)"/);
|
||||
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
|
||||
if (filenameMatch) {
|
||||
// File field
|
||||
const filename = filenameMatch[1];
|
||||
const contentTypeMatch = headerText.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
// Remove trailing boundary and newlines from content
|
||||
let fileData = contentBuffer;
|
||||
const endBoundary = Buffer.from(`\r\n--${boundary}--`, 'utf-8');
|
||||
const endIndex = fileData.indexOf(endBoundary);
|
||||
if (endIndex !== -1) {
|
||||
fileData = fileData.slice(0, endIndex);
|
||||
}
|
||||
// Remove trailing newlines
|
||||
while (fileData.length > 0 && (fileData[fileData.length - 1] === 0x0A || fileData[fileData.length - 1] === 0x0D)) {
|
||||
fileData = fileData.slice(0, -1);
|
||||
}
|
||||
|
||||
files[name] = {
|
||||
filename,
|
||||
contentType: contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream',
|
||||
data: fileData
|
||||
};
|
||||
sdk.log.info('global.profile', `Parsed file: ${name}, filename: ${filename}, size: ${fileData.length}`);
|
||||
} else {
|
||||
// Regular field
|
||||
const fieldValue = contentBuffer.toString('utf-8').trim();
|
||||
// Remove trailing boundary
|
||||
const cleanValue = fieldValue.replace(/\r\n--.*$/, '').trim();
|
||||
fields[name] = cleanValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sdk.log.info('global.profile', `Parsed ${Object.keys(files).length} files, ${Object.keys(fields).length} fields`);
|
||||
resolve({ fields, files });
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error parsing multipart: ${err.message}`);
|
||||
sdk.log.error('global.profile', `Error stack: ${err.stack}`);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLocalPeerId,
|
||||
isDatabaseAvailable,
|
||||
parseMultipartFormData
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Global Profile Plugin - Database Watcher
|
||||
*
|
||||
* Handles database change watching and broadcasting updates
|
||||
*/
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
const { getProfileFromDB } = require('./database');
|
||||
const { broadcastProfileUpdate } = require('./websocket');
|
||||
|
||||
/**
|
||||
* Watch for database changes and broadcast updates
|
||||
*/
|
||||
async function setupDatabaseWatcher() {
|
||||
// Wait for database to be available with retries
|
||||
let retries = 30;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
await sdk.db.ready();
|
||||
if (!sdk.db.closed) {
|
||||
sdk.db.watch(async (...args) => {
|
||||
try {
|
||||
const update = args[0];
|
||||
|
||||
// Check if we have a valid update for profile changes
|
||||
if (update && typeof update === 'object' && update.collection === '@profile/profiles') {
|
||||
sdk.log.info('global.profile', `Database watcher detected profile change: ${JSON.stringify(update)}`);
|
||||
const profile = await getProfileFromDB(update.key?.peerId);
|
||||
if (profile) {
|
||||
// Parse JSON fields for broadcast
|
||||
try {
|
||||
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
|
||||
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
|
||||
} catch (err) {
|
||||
profile.customFields = {};
|
||||
profile.tags = [];
|
||||
}
|
||||
|
||||
sdk.log.info('global.profile', `Database watcher broadcasting updated profile for ${update.key?.peerId}`);
|
||||
broadcastProfileUpdate({
|
||||
type: 'profile-update',
|
||||
profile: profile
|
||||
});
|
||||
} else {
|
||||
sdk.log.warn('global.profile', `Database watcher detected change for ${update.key?.peerId} but profile not found`);
|
||||
}
|
||||
} else if (update) {
|
||||
sdk.log.debug('global.profile', `Database watcher received update for different collection: ${update.collection || 'unknown'}`);
|
||||
}
|
||||
// Ignore other update types or setup calls
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error in database watcher: ${err.message}`);
|
||||
}
|
||||
});
|
||||
sdk.log.info('global.profile', 'Database watcher set up');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Database not ready yet, continue waiting
|
||||
if (err.message && err.message.includes('Database not initialized')) {
|
||||
retries--;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
||||
continue;
|
||||
}
|
||||
// Other error - log and return
|
||||
sdk.log.error('global.profile', `Error setting up database watcher: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (retries === 0) {
|
||||
sdk.log.warn('global.profile', 'Database watcher setup timed out - database not available after 30 seconds');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setupDatabaseWatcher
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Global Profile Plugin - WebSocket Management
|
||||
*
|
||||
* Handles WebSocket connections, broadcasting, and real-time updates
|
||||
*/
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
const { getProfileFromDB, getAllProfilesFromDB } = require('./database');
|
||||
|
||||
/**
|
||||
* Broadcast profile update to WebSocket clients and all peers
|
||||
*/
|
||||
function broadcastProfileUpdate(data) {
|
||||
const message = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
sdk.log.debug('global.profile', `Broadcasting profile update: ${data.type} for peer ${data.profile?.peerId || data.peerId}`);
|
||||
|
||||
// Broadcast to local WebSocket clients
|
||||
const localClients = sdk.websocket.broadcast(message);
|
||||
sdk.log.debug('global.profile', `Sent to ${localClients} local WebSocket client(s)`);
|
||||
|
||||
// Broadcast to all connected P2P peers via channels
|
||||
// Peers will forward to their local WebSocket clients
|
||||
try {
|
||||
const peerCount = sdk.channels.broadcast('profile-updates', message);
|
||||
if (peerCount > 0) {
|
||||
sdk.log.debug('global.profile', `Sent to ${peerCount} peer(s) via P2P channels`);
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.debug('global.profile', `Error broadcasting to peers: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get replication status for HyperDB (avatars are stored in HyperDB)
|
||||
*/
|
||||
function getReplicationStatus() {
|
||||
const status = {
|
||||
hyperdb: { active: false, peers: 0 }
|
||||
};
|
||||
|
||||
try {
|
||||
// Get HyperDB replication status
|
||||
const dbReplication = sdk.db.replication;
|
||||
if (dbReplication) {
|
||||
const dbStatus = dbReplication.getStatus();
|
||||
if (dbStatus) {
|
||||
status.hyperdb = {
|
||||
active: dbStatus.active,
|
||||
peers: dbStatus.peers || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.debug('global.profile', `Error getting HyperDB replication status: ${err.message}`);
|
||||
}
|
||||
|
||||
// Avatars are now stored in HyperDB, so no separate Hyperdrive status needed
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket handlers
|
||||
*/
|
||||
function setupWebSocketHandlers() {
|
||||
// Initialize WebSocket server
|
||||
if (!sdk.websocket.initialize()) {
|
||||
sdk.log.warn('global.profile', 'Failed to initialize WebSocket server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Track previous replication status to detect changes
|
||||
let previousReplicationStatus = JSON.stringify(getReplicationStatus());
|
||||
|
||||
sdk.websocket.on('connection', async (ws) => {
|
||||
const clientCount = sdk.websocket.getClientCount();
|
||||
sdk.log.info('global.profile', `WebSocket client connected (${clientCount} total)`);
|
||||
|
||||
// Initial profile data is now loaded via REST API
|
||||
// WebSocket is only used for real-time updates (profile-update, profile-deleted)
|
||||
// The request-profiles handler below is kept for backward compatibility
|
||||
});
|
||||
|
||||
sdk.websocket.on('message', async (ws, message) => {
|
||||
try {
|
||||
if (message.type === 'request-profiles') {
|
||||
try {
|
||||
const profiles = await getAllProfilesFromDB();
|
||||
sdk.websocket.send(ws, {
|
||||
type: 'profiles',
|
||||
profiles: profiles.map(p => {
|
||||
try {
|
||||
p.customFields = p.customFields ? JSON.parse(p.customFields) : {};
|
||||
p.tags = p.tags ? JSON.parse(p.tags) : [];
|
||||
} catch (err) {
|
||||
p.customFields = {};
|
||||
p.tags = [];
|
||||
}
|
||||
p.online = sdk.state.peerIds.includes(p.peerId);
|
||||
return p;
|
||||
}),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error getting profiles for WebSocket: ${err.message}`);
|
||||
sdk.websocket.send(ws, {
|
||||
type: 'profiles',
|
||||
profiles: [],
|
||||
timestamp: Date.now(),
|
||||
error: 'Database not ready'
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'request-replication-status') {
|
||||
// Send current replication status
|
||||
const replicationStatus = getReplicationStatus();
|
||||
sdk.websocket.send(ws, {
|
||||
type: 'replication-status',
|
||||
data: replicationStatus,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.error('global.profile', `Error handling WebSocket message: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast replication status updates periodically
|
||||
setInterval(() => {
|
||||
const clientCount = sdk.websocket.getClientCount();
|
||||
if (clientCount > 0) {
|
||||
const currentStatus = getReplicationStatus();
|
||||
const currentStatusStr = JSON.stringify(currentStatus);
|
||||
|
||||
// Only broadcast if status changed
|
||||
if (currentStatusStr !== previousReplicationStatus) {
|
||||
const sent = sdk.websocket.broadcast({
|
||||
type: 'replication-status',
|
||||
data: currentStatus,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
previousReplicationStatus = currentStatusStr;
|
||||
sdk.log.debug('global.profile', `Broadcasted replication status update to ${sent} client(s)`);
|
||||
}
|
||||
}
|
||||
}, 2000); // Check every 2 seconds
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
broadcastProfileUpdate,
|
||||
getReplicationStatus,
|
||||
setupWebSocketHandlers
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/* Profile Modal Styles - Dark Glass Theme */
|
||||
|
||||
/* CSS Variables for standalone modal */
|
||||
:root {
|
||||
/* Modern Color Palette */
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--primary-light: #818cf8;
|
||||
--secondary: #8b5cf6;
|
||||
--accent: #ec4899;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
|
||||
/* Background Colors - Darker for glass effect */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #0f1419;
|
||||
--bg-tertiary: #1a1f2e;
|
||||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-glass-strong: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
|
||||
/* Borders & Shadows - Enhanced for glass */
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-color-strong: rgba(255, 255, 255, 0.15);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 30px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.profile-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profile-modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.profile-modal-content {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(40px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(200%);
|
||||
border: 1px solid var(--border-color-strong);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-modal-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.profile-modal-content > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.profile-modal-close::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.profile-modal-close:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-color-strong);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.profile-modal-loading,
|
||||
.profile-modal-error {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(99, 102, 241, 0.3);
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.profile-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-modal-avatar-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-modal-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #6366f1;
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.profile-modal-avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
border: 3px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.profile-modal-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-modal-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bot-badge {
|
||||
display: inline-block;
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.5);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bot-badge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.profile-modal-peerid {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-tertiary);
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-modal-bio {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-modal-bio-text {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Profile Tags Section */
|
||||
.profile-modal-tags {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-modal-tags-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.profile-modal-tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-modal-tag {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.profile-modal-tag::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.profile-modal-tag:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-color-strong);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.profile-modal-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-modal-detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-modal-detail-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.profile-modal-detail-item:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-color-strong);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.profile-modal-detail-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-modal-detail-link {
|
||||
color: var(--primary-light);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-modal-detail-link:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.profile-modal-detail-text {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for modal */
|
||||
.profile-modal-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.profile-modal-content::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.profile-modal-content::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.profile-modal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Profile Modal Actions */
|
||||
.profile-modal-actions {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-modal-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-modal-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.profile-modal-action-btn > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-modal-action-btn:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-color-strong);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.profile-modal-action-btn-block {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.profile-modal-action-btn-block:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
box-shadow: 0 4px 6px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.profile-modal-action-btn-unblock {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.profile-modal-action-btn-unblock:hover {
|
||||
background: rgba(16, 185, 129, 0.3);
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow: 0 4px 6px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.profile-modal-action-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
:root {
|
||||
/* Modern Color Palette */
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--primary-light: #818cf8;
|
||||
--secondary: #8b5cf6;
|
||||
--accent: #ec4899;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
|
||||
/* Background Colors - Darker for glass effect */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #0f1419;
|
||||
--bg-tertiary: #1a1f2e;
|
||||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-glass-strong: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
|
||||
/* Borders & Shadows - Enhanced for glass */
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-color-strong: rgba(255, 255, 255, 0.15);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 30px rgba(99, 102, 241, 0.4);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0a0e1a 0%, #0f1419 50%, #1a1f2e 100%);
|
||||
background-attachment: fixed;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
padding: calc(var(--spacing-lg) - 20px) var(--spacing-xl);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(10, 14, 26, 0.7);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--success);
|
||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: var(--error);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected::before {
|
||||
background: var(--error);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.profiles-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.profiles-count #profilesCountValue {
|
||||
font-weight: 600;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-md);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.profile-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--primary);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
border: 2px solid var(--border-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.avatar-upload-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg-primary);
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.avatar-upload-label:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
background: rgba(15, 20, 30, 0.6);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--transition-base);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
background: rgba(15, 20, 30, 0.8);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-group input.readonly {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.form-section-divider {
|
||||
margin: var(--spacing-sm) 0 var(--spacing-sm) 0;
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-section-divider h3 {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form-group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-group-header label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.2rem var(--spacing-sm);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle-btn:hover:not(:disabled) {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled:hover:not(:disabled) {
|
||||
background: #0ea66e;
|
||||
}
|
||||
|
||||
.toggle-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.2), 0 2px 4px -1px rgba(99, 102, 241, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.5);
|
||||
border-color: rgba(99, 102, 241, 0.7);
|
||||
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.3), 0 4px 6px -2px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-glass);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-color-strong);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profiles-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profiles-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--transition-base);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
#scroll-sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profiles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.profile-item {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
transition: all var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-item:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
transform: translateX(4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.profile-item-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-item-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bot-badge {
|
||||
display: inline-block;
|
||||
background: var(--primary, #6366f1);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-item-bio {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.profile-item-details {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
margin: 0.15rem 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-item-details a {
|
||||
color: var(--primary-light);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.profile-item-details a:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.profile-item-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-block;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.tag-badge:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.profile-item-peerid {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.7rem;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Tag Editor */
|
||||
.tag-editor {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
transition: all var(--transition-base);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.tags-input-inline {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--transition-base);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tags-input-inline:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.tag-editor:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-editor-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.tag-editor-badge:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tag-remove:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
|
||||
#tags-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--spacing-md);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-xl);
|
||||
right: var(--spacing-xl);
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-xl);
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all var(--transition-base);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
margin-top: var(--spacing-sm);
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="448" height="512">
|
||||
<path fill="#3b82f6" d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<title>Global Profile - P2NS</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="/auth-utils.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Global Profile</h1>
|
||||
<p class="subtitle">Manage your profile across the P2NS network</p>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<div class="status-indicator" id="statusIndicator" aria-label="Connection status"></div>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="profile-section">
|
||||
<div class="profile-card">
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-container">
|
||||
<img id="avatar-preview" src="" alt="Avatar" class="avatar-image" style="display: none;" onerror="this.style.display='none'; document.getElementById('avatar-placeholder').style.display='block';">
|
||||
<div id="avatar-placeholder" class="avatar-placeholder" style="display: block;">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<label for="avatar-upload" class="avatar-upload-label">
|
||||
<input type="file" id="avatar-upload" accept="image/*" style="display: none;">
|
||||
<span class="upload-icon">📷</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="avatar-hint">Click to upload avatar</p>
|
||||
</div>
|
||||
|
||||
<form id="profile-form" class="profile-form">
|
||||
<div class="form-group">
|
||||
<label for="display-name">Display Name</label>
|
||||
<input type="text" id="display-name" name="displayName" placeholder="Enter your display name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bio">Bio</label>
|
||||
<textarea id="bio" name="bio" placeholder="Tell us about yourself..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Peer ID</label>
|
||||
<input type="text" id="peer-id" readonly class="readonly">
|
||||
</div>
|
||||
|
||||
<div class="form-section-divider">
|
||||
<h3>Additional Details</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group-header">
|
||||
<label for="email">Email</label>
|
||||
<button type="button" class="toggle-btn" id="email-toggle" data-field="email">
|
||||
<span class="toggle-label">Enable</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="email" id="email" name="email" placeholder="[email protected]" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group-header">
|
||||
<label for="website">Website</label>
|
||||
<button type="button" class="toggle-btn" id="website-toggle" data-field="website">
|
||||
<span class="toggle-label">Enable</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="url" id="website" name="website" placeholder="https://example.com" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group-header">
|
||||
<label for="x-handle">X (Twitter) Handle</label>
|
||||
<button type="button" class="toggle-btn" id="x-handle-toggle" data-field="xUsername">
|
||||
<span class="toggle-label">Enable</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" id="x-handle" name="xUsername" placeholder="@username" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input type="text" id="tags-input" name="tags" class="tags-input-inline" placeholder="Type and press Enter to add a tag" autocomplete="off">
|
||||
<div class="tag-editor">
|
||||
<div class="tag-list" id="tag-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="save-btn">
|
||||
<span class="btn-text">Save Profile</span>
|
||||
<span class="btn-loader" style="display: none;">⏳</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" id="delete-btn">Delete Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profiles-section">
|
||||
<h2>Network Profiles (<span id="profilesCountValue">-</span>)</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" id="profile-search" class="search-input" placeholder="Search profiles by name, bio, email, website, handle, or peer ID...">
|
||||
</div>
|
||||
<div id="profiles-list" class="profiles-list">
|
||||
<div class="loading">Loading profiles...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Profile Modal Component
|
||||
* Reusable modal for displaying user profiles across plugins
|
||||
*
|
||||
* Usage:
|
||||
* 1. Include the CSS: <link rel="stylesheet" href="https://global.profile/css/profile-modal.css">
|
||||
* 2. Include the HTML: (include profile-modal.html content or use iframe/component loader)
|
||||
* 3. Include this JS: <script src="https://global.profile/js/profile-modal.js"></script>
|
||||
* 4. Use: window.ProfileModal.open(peerId)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Profile cache with TTL
|
||||
const profileCache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Global profile domain
|
||||
const GLOBAL_PROFILE_DOMAIN = 'global.profile';
|
||||
|
||||
/**
|
||||
* Get profile from cache or fetch from API
|
||||
*/
|
||||
async function getProfile(peerId) {
|
||||
// Check cache first
|
||||
const cached = profileCache.get(peerId);
|
||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||||
return cached.profile;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch from global.profile API
|
||||
// Use absolute URL to access global.profile domain
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// Construct URL - use absolute URL for cross-domain access
|
||||
const profileUrl = `${protocol}//${GLOBAL_PROFILE_DOMAIN}/api/profile/${encodeURIComponent(peerId)}`;
|
||||
|
||||
const response = await fetch(profileUrl);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null; // Profile doesn't exist
|
||||
}
|
||||
throw new Error(`Failed to fetch profile: ${response.status}`);
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
|
||||
// Cache the profile
|
||||
profileCache.set(peerId, {
|
||||
profile,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return profile;
|
||||
} catch (err) {
|
||||
console.error('Error fetching profile:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar URL for a peer
|
||||
*/
|
||||
function getAvatarUrl(peerId, size = 128) {
|
||||
if (!peerId) return '';
|
||||
const protocol = window.location.protocol;
|
||||
return `${protocol}//${GLOBAL_PROFILE_DOMAIN}/api/profile/avatar/${encodeURIComponent(peerId)}/${size}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open profile modal for a peer
|
||||
*/
|
||||
async function openProfileModal(peerId) {
|
||||
if (!peerId) {
|
||||
console.error('ProfileModal: peerId is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('profile-modal');
|
||||
if (!modal) {
|
||||
console.error('ProfileModal: Modal element not found. Make sure profile-modal.html is included.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show modal and loading state
|
||||
modal.style.display = 'flex';
|
||||
document.getElementById('profile-modal-loading').style.display = 'block';
|
||||
document.getElementById('profile-modal-error').style.display = 'none';
|
||||
document.getElementById('profile-modal-body').style.display = 'none';
|
||||
|
||||
try {
|
||||
// Fetch profile
|
||||
const profile = await getProfile(peerId);
|
||||
|
||||
if (!profile) {
|
||||
// Show error state
|
||||
document.getElementById('profile-modal-loading').style.display = 'none';
|
||||
document.getElementById('profile-modal-error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate modal with profile data
|
||||
const displayName = profile.displayName || 'Anonymous';
|
||||
const isBot = profile.customFields && (profile.customFields.isBot === true || profile.customFields.botTag === 'BOT');
|
||||
const nameElement = document.getElementById('profile-modal-name');
|
||||
if (isBot) {
|
||||
nameElement.innerHTML = escapeHtml(displayName) + ' <span class="bot-badge">BOT</span>';
|
||||
} else {
|
||||
nameElement.textContent = displayName;
|
||||
}
|
||||
document.getElementById('profile-modal-peerid').textContent = profile.peerId || peerId;
|
||||
|
||||
// Avatar
|
||||
const avatarImg = document.getElementById('profile-modal-avatar');
|
||||
const avatarPlaceholder = document.getElementById('profile-modal-avatar-placeholder');
|
||||
const avatarUrl = getAvatarUrl(peerId, 128);
|
||||
avatarImg.src = avatarUrl + '?t=' + Date.now(); // Add timestamp to bust cache
|
||||
avatarImg.style.display = 'block';
|
||||
avatarPlaceholder.style.display = 'none';
|
||||
avatarImg.onerror = () => {
|
||||
avatarImg.style.display = 'none';
|
||||
avatarPlaceholder.style.display = 'flex';
|
||||
};
|
||||
|
||||
// Bio
|
||||
const bioContainer = document.getElementById('profile-modal-bio-container');
|
||||
const bioText = document.getElementById('profile-modal-bio');
|
||||
if (profile.bio && profile.bio.trim()) {
|
||||
bioText.textContent = profile.bio;
|
||||
bioContainer.style.display = 'block';
|
||||
} else {
|
||||
bioContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Additional details - check if fields are enabled (via customFields or direct flags)
|
||||
const emailItem = document.getElementById('profile-modal-email-item');
|
||||
const emailLink = document.getElementById('profile-modal-email');
|
||||
const emailEnabled = profile.email || (profile.customFields && profile.customFields.emailEnabled);
|
||||
if (emailEnabled && profile.email) {
|
||||
emailLink.href = `mailto:${escapeHtml(profile.email)}`;
|
||||
emailLink.textContent = escapeHtml(profile.email);
|
||||
emailItem.style.display = 'flex';
|
||||
} else {
|
||||
emailItem.style.display = 'none';
|
||||
}
|
||||
|
||||
const websiteItem = document.getElementById('profile-modal-website-item');
|
||||
const websiteLink = document.getElementById('profile-modal-website');
|
||||
const websiteEnabled = profile.website || (profile.customFields && profile.customFields.websiteEnabled);
|
||||
if (websiteEnabled && profile.website) {
|
||||
let websiteUrl = profile.website;
|
||||
if (!websiteUrl.startsWith('http://') && !websiteUrl.startsWith('https://')) {
|
||||
websiteUrl = 'https://' + websiteUrl;
|
||||
}
|
||||
websiteLink.href = websiteUrl;
|
||||
websiteLink.textContent = escapeHtml(profile.website);
|
||||
websiteItem.style.display = 'flex';
|
||||
} else {
|
||||
websiteItem.style.display = 'none';
|
||||
}
|
||||
|
||||
const xItem = document.getElementById('profile-modal-x-item');
|
||||
const xLink = document.getElementById('profile-modal-x');
|
||||
const xEnabled = profile.xUsername || (profile.customFields && profile.customFields.xHandleEnabled);
|
||||
if (xEnabled && profile.xUsername) {
|
||||
const xHandle = profile.xUsername.replace('@', '');
|
||||
const xUrl = `https://x.com/${xHandle}`;
|
||||
xLink.href = xUrl;
|
||||
xLink.textContent = escapeHtml(profile.xUsername);
|
||||
xItem.style.display = 'flex';
|
||||
} else {
|
||||
xItem.style.display = 'none';
|
||||
}
|
||||
|
||||
// GitHub
|
||||
const githubItem = document.getElementById('profile-modal-github-item');
|
||||
const githubLink = document.getElementById('profile-modal-github');
|
||||
if (githubItem && githubLink) {
|
||||
if (profile.github) {
|
||||
const githubHandle = profile.github.replace('@', '');
|
||||
const githubUrl = `https://github.com/${githubHandle}`;
|
||||
githubLink.href = githubUrl;
|
||||
githubLink.textContent = escapeHtml(profile.github);
|
||||
githubItem.style.display = 'flex';
|
||||
} else {
|
||||
githubItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Discord
|
||||
const discordItem = document.getElementById('profile-modal-discord-item');
|
||||
const discordText = document.getElementById('profile-modal-discord');
|
||||
if (discordItem && discordText) {
|
||||
if (profile.discord) {
|
||||
discordText.textContent = escapeHtml(profile.discord);
|
||||
discordItem.style.display = 'flex';
|
||||
} else {
|
||||
discordItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Location
|
||||
const locationItem = document.getElementById('profile-modal-location-item');
|
||||
const locationText = document.getElementById('profile-modal-location');
|
||||
if (locationItem && locationText) {
|
||||
if (profile.location) {
|
||||
locationText.textContent = escapeHtml(profile.location);
|
||||
locationItem.style.display = 'flex';
|
||||
} else {
|
||||
locationItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
const tagsContainer = document.getElementById('profile-modal-tags-container');
|
||||
const tagsList = document.getElementById('profile-modal-tags-list');
|
||||
if (tagsContainer && tagsList) {
|
||||
let tagsArray = [];
|
||||
if (Array.isArray(profile.tags)) {
|
||||
tagsArray = profile.tags;
|
||||
} else if (typeof profile.tags === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(profile.tags);
|
||||
tagsArray = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
// If it's a comma-separated string, split it
|
||||
if (profile.tags.trim()) {
|
||||
tagsArray = profile.tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsArray && tagsArray.length > 0) {
|
||||
tagsList.innerHTML = '';
|
||||
tagsArray.forEach(tag => {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.className = 'profile-modal-tag';
|
||||
tagElement.textContent = escapeHtml(tag);
|
||||
tagsList.appendChild(tagElement);
|
||||
});
|
||||
tagsContainer.style.display = 'block';
|
||||
} else {
|
||||
tagsContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide block button (if blocking functions are available from peer.chat or SDK)
|
||||
const actionsContainer = document.getElementById('profile-modal-actions');
|
||||
const blockBtn = document.getElementById('profile-modal-block-btn');
|
||||
const unblockBtn = document.getElementById('profile-modal-unblock-btn');
|
||||
|
||||
if (actionsContainer && blockBtn && unblockBtn && typeof window.isPeerBlocked === 'function') {
|
||||
const isBlocked = window.isPeerBlocked(peerId);
|
||||
const isOwnProfile = window.localPeerId && peerId === window.localPeerId;
|
||||
|
||||
if (!isOwnProfile) {
|
||||
blockBtn.style.display = isBlocked ? 'none' : 'flex';
|
||||
unblockBtn.style.display = isBlocked ? 'flex' : 'none';
|
||||
actionsContainer.style.display = 'flex';
|
||||
|
||||
// Update button handlers
|
||||
blockBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof window.blockPeer === 'function') {
|
||||
window.blockPeer(peerId);
|
||||
// Update button state
|
||||
blockBtn.style.display = 'none';
|
||||
unblockBtn.style.display = 'flex';
|
||||
}
|
||||
};
|
||||
|
||||
unblockBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof window.unblockPeer === 'function') {
|
||||
window.unblockPeer(peerId);
|
||||
// Update button state
|
||||
unblockBtn.style.display = 'none';
|
||||
blockBtn.style.display = 'flex';
|
||||
}
|
||||
};
|
||||
} else {
|
||||
actionsContainer.style.display = 'none';
|
||||
}
|
||||
} else if (actionsContainer) {
|
||||
actionsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show body
|
||||
document.getElementById('profile-modal-loading').style.display = 'none';
|
||||
document.getElementById('profile-modal-body').style.display = 'block';
|
||||
} catch (err) {
|
||||
console.error('Error loading profile:', err);
|
||||
document.getElementById('profile-modal-loading').style.display = 'none';
|
||||
document.getElementById('profile-modal-error').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close profile modal
|
||||
*/
|
||||
function closeProfileModal() {
|
||||
const modal = document.getElementById('profile-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Track if listeners are already attached
|
||||
let listenersAttached = false;
|
||||
|
||||
/**
|
||||
* Initialize modal event listeners
|
||||
* Uses event delegation so it works even if modal is added dynamically
|
||||
*/
|
||||
function initModal() {
|
||||
// Only attach document-level listeners once
|
||||
if (listenersAttached) {
|
||||
// But always try to attach direct listeners to the close button if modal exists
|
||||
attachDirectListeners();
|
||||
return;
|
||||
}
|
||||
listenersAttached = true;
|
||||
|
||||
// Use event delegation for close button (check if clicked element or its parent is the close button)
|
||||
document.addEventListener('click', (e) => {
|
||||
const closeBtn = e.target.closest('.profile-modal-close');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeProfileModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check backdrop click
|
||||
if (e.target && e.target.classList.contains('profile-modal-backdrop')) {
|
||||
closeProfileModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ESC key - check if modal is visible
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('profile-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeProfileModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also attach direct listeners if modal already exists
|
||||
attachDirectListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach direct event listeners to the close button
|
||||
*/
|
||||
function attachDirectListeners() {
|
||||
const modal = document.getElementById('profile-modal');
|
||||
if (modal) {
|
||||
const closeBtn = modal.querySelector('.profile-modal-close');
|
||||
if (closeBtn && !closeBtn.hasAttribute('data-listener-attached')) {
|
||||
closeBtn.setAttribute('data-listener-attached', 'true');
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeProfileModal();
|
||||
});
|
||||
// Also add onclick as fallback
|
||||
closeBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeProfileModal();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to make avatar clickable
|
||||
* Call this on avatar elements to make them open the profile modal when clicked
|
||||
*/
|
||||
function makeAvatarClickable(avatarElement, peerId) {
|
||||
if (!avatarElement || !peerId) return;
|
||||
|
||||
avatarElement.style.cursor = 'pointer';
|
||||
avatarElement.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openProfileModal(peerId);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize immediately (event delegation will work even if modal doesn't exist yet)
|
||||
initModal();
|
||||
|
||||
// Also try to initialize when DOM is ready (in case modal is already there)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', attachDirectListeners);
|
||||
}
|
||||
|
||||
// Export to global scope
|
||||
window.ProfileModal = {
|
||||
open: openProfileModal,
|
||||
close: closeProfileModal,
|
||||
getProfile: getProfile,
|
||||
getAvatarUrl: getAvatarUrl,
|
||||
makeAvatarClickable: makeAvatarClickable,
|
||||
init: initModal // Export init so it can be called after HTML is loaded
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Global Profile",
|
||||
"short_name": "Profile",
|
||||
"description": "Universal user identity system for all P2NS plugins with P2P profile replication",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["social", "productivity"],
|
||||
"lang": "en"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<!-- Profile Modal Component -->
|
||||
<div id="profile-modal" class="profile-modal" style="display: none;">
|
||||
<div class="profile-modal-backdrop"></div>
|
||||
<div class="profile-modal-content">
|
||||
<button class="profile-modal-close" aria-label="Close" onclick="if(window.ProfileModal){window.ProfileModal.close();}">×</button>
|
||||
|
||||
<div class="profile-modal-loading" id="profile-modal-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-error" id="profile-modal-error" style="display: none;">
|
||||
<p>Failed to load profile</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-body" id="profile-modal-body" style="display: none;">
|
||||
<div class="profile-modal-header">
|
||||
<div class="profile-modal-avatar-container">
|
||||
<img id="profile-modal-avatar" class="profile-modal-avatar" alt="Avatar" onerror="this.style.display='none'; document.getElementById('profile-modal-avatar-placeholder').style.display='flex';">
|
||||
<div id="profile-modal-avatar-placeholder" class="profile-modal-avatar-placeholder" style="display: none;">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-modal-header-info">
|
||||
<h2 id="profile-modal-name" class="profile-modal-name">Loading...</h2>
|
||||
<p id="profile-modal-peerid" class="profile-modal-peerid">...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-bio" id="profile-modal-bio-container" style="display: none;">
|
||||
<p id="profile-modal-bio" class="profile-modal-bio-text"></p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-tags" id="profile-modal-tags-container" style="display: none;">
|
||||
<div class="profile-modal-tags-label">Tags</div>
|
||||
<div class="profile-modal-tags-list" id="profile-modal-tags-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-details" id="profile-modal-details">
|
||||
<div class="profile-modal-detail-item" id="profile-modal-email-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">📧</span>
|
||||
<a id="profile-modal-email" href="#" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-website-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">🌐</span>
|
||||
<a id="profile-modal-website" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-x-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">🐦</span>
|
||||
<a id="profile-modal-x" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-github-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">💻</span>
|
||||
<a id="profile-modal-github" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-discord-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">💬</span>
|
||||
<span id="profile-modal-discord" class="profile-modal-detail-text"></span>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-location-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">📍</span>
|
||||
<span id="profile-modal-location" class="profile-modal-detail-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-actions" id="profile-modal-actions" style="display: none;">
|
||||
<button id="profile-modal-block-btn" class="profile-modal-action-btn profile-modal-action-btn-block" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
|
||||
</svg>
|
||||
Block
|
||||
</button>
|
||||
<button id="profile-modal-unblock-btn" class="profile-modal-action-btn profile-modal-action-btn-unblock" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user