Compare commits
3 Commits
afccb27bd4
...
75b8567a28
| Author | SHA1 | Date | |
|---|---|---|---|
|
75b8567a28
|
|||
|
3aa5561a07
|
|||
|
c0ebb496fe
|
536
DysonNetwork.Zone/Publication/API_DOCS.md
Normal file
536
DysonNetwork.Zone/Publication/API_DOCS.md
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# Publication Site Management API
|
||||||
|
|
||||||
|
This API provides file management capabilities for self-managed publication sites on the Dyson Network platform. It allows authenticated users with editor permissions to manage static files for their sites.
|
||||||
|
|
||||||
|
When using with the gateway, the `/api` should be replaced with the `/zone`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Publication API provides comprehensive management capabilities for publication sites and their content on the Dyson Network platform. It includes:
|
||||||
|
|
||||||
|
### Site Management
|
||||||
|
- Site CRUD operations (Create, Read, Update, Delete)
|
||||||
|
- Publisher-based site organization
|
||||||
|
- Support for FullyManaged and SelfManaged site modes
|
||||||
|
|
||||||
|
### Page Management
|
||||||
|
- Page CRUD operations within sites
|
||||||
|
- Support for HTML pages and redirects
|
||||||
|
- Flexible configuration using JSON config objects
|
||||||
|
|
||||||
|
### File Management (SelfManaged Sites Only)
|
||||||
|
- File and directory listing
|
||||||
|
- File upload with size validation
|
||||||
|
- File content reading and editing
|
||||||
|
- File downloading with appropriate MIME types
|
||||||
|
- File deletion
|
||||||
|
- Total site size tracking and limits
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
```
|
||||||
|
/api/sites/{siteId}/files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
All endpoints require authentication via JWT bearer token or similar authentication mechanism configured in the application.
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- User must be authenticated
|
||||||
|
- Site must exist and be in `SelfManaged` mode
|
||||||
|
- User must have `Editor` role in the site's publisher
|
||||||
|
|
||||||
|
### File Limits
|
||||||
|
- **Individual file size limit**: 1 MB (1,048,576 bytes)
|
||||||
|
- **Total site size limit**: 25 MB (26,214,400 bytes)
|
||||||
|
- Files are stored in the site's dedicated directory structure
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
## Site Management Endpoints
|
||||||
|
|
||||||
|
The following endpoints handle publication site CRUD operations.
|
||||||
|
|
||||||
|
### Get Site (Public)
|
||||||
|
|
||||||
|
Get a publication site by publisher name and slug.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/{pubName}/{slug}`
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"slug": "my-site",
|
||||||
|
"name": "My Site",
|
||||||
|
"description": "A description of my site",
|
||||||
|
"mode": "FullyManaged",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "456e7890-e89b-12d3-a456-426614174000",
|
||||||
|
"type": "HtmlPage",
|
||||||
|
"path": "/",
|
||||||
|
"config": {
|
||||||
|
"content": "<h1>Home</h1>",
|
||||||
|
"title": "Home Page"
|
||||||
|
},
|
||||||
|
"site_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publisher_id": "456e7890-e89b-12d3-a456-426614174000",
|
||||||
|
"account_id": "789e0123-e89b-12d3-a456-426614174000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Sites for Publisher
|
||||||
|
|
||||||
|
List all sites for a specific publisher.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/{pubName}`
|
||||||
|
|
||||||
|
**Authorization**: Required (Viewer role or higher in publisher)
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"slug": "site1",
|
||||||
|
"name": "Site One",
|
||||||
|
"description": "First site",
|
||||||
|
"mode": "SelfManaged",
|
||||||
|
"publisher_id": "456e7890-e89b-12d3-a456-426614174000",
|
||||||
|
"account_id": "789e0123-e89b-12d3-a456-426614174000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Owned Sites
|
||||||
|
|
||||||
|
List all sites for publishers where the authenticated user is a member.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/me`
|
||||||
|
|
||||||
|
**Authorization**: Required
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Array of sites as shown above.
|
||||||
|
|
||||||
|
### Create Site
|
||||||
|
|
||||||
|
Create a new publication site.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/sites/{pubName}`
|
||||||
|
|
||||||
|
**Authorization**: Required (Editor role or higher in publisher)
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "SelfManaged",
|
||||||
|
"slug": "my-new-site",
|
||||||
|
"name": "My New Site",
|
||||||
|
"description": "Description of my new site"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Returns created site object.
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- User must have appropriate permissions in the publisher
|
||||||
|
- Slug must be unique within the publisher
|
||||||
|
- Name is required, max 4096 characters
|
||||||
|
- Description max 8192 characters
|
||||||
|
|
||||||
|
### Update Site
|
||||||
|
|
||||||
|
Update an existing publication site.
|
||||||
|
|
||||||
|
**Endpoint**: `PATCH /api/sites/{pubName}/{id}`
|
||||||
|
|
||||||
|
**Authorization**: Required (Editor role or higher in publisher)
|
||||||
|
|
||||||
|
**Request Body**: Same as Create Site, all fields optional.
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Returns updated site object.
|
||||||
|
|
||||||
|
### Delete Site
|
||||||
|
|
||||||
|
Delete a publication site and all its associated pages.
|
||||||
|
|
||||||
|
**Endpoint**: `DELETE /api/sites/{pubName}/{id}`
|
||||||
|
|
||||||
|
**Authorization**: Required (Editor role or higher in publisher)
|
||||||
|
|
||||||
|
**Response**: `204 No Content`
|
||||||
|
|
||||||
|
## Page Management Endpoints
|
||||||
|
|
||||||
|
The following endpoints handle publication page CRUD operations.
|
||||||
|
|
||||||
|
### Render Page (Public)
|
||||||
|
|
||||||
|
Render a publication page for public access.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/site/{slug}/page`
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `path`: Page path (defaults to "/")
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Returns page object.
|
||||||
|
|
||||||
|
### List Pages for Site
|
||||||
|
|
||||||
|
List all pages belonging to a specific site.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/{pubName}/{siteSlug}/pages`
|
||||||
|
|
||||||
|
**Authorization**: Required (Viewer role or higher in publisher)
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"type": "HtmlPage",
|
||||||
|
"path": "/",
|
||||||
|
"config": {
|
||||||
|
"content": "<h1>Welcome</h1>",
|
||||||
|
"title": "Home Page"
|
||||||
|
},
|
||||||
|
"site_id": "456e7890-e89b-12d3-a456-426614174000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Page
|
||||||
|
|
||||||
|
Get a specific page by ID.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/pages/{id}`
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Returns page object as shown above.
|
||||||
|
|
||||||
|
### Create Page
|
||||||
|
|
||||||
|
Create a new publication page.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/sites/{pubName}/{siteSlug}/pages`
|
||||||
|
|
||||||
|
**Authorization**: Required (Editor role or higher in publisher)
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "HtmlPage",
|
||||||
|
"path": "/about",
|
||||||
|
"config": {
|
||||||
|
"content": "<h1>About Us</h1><p>Content here</p>",
|
||||||
|
"title": "About Page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Returns created page object.
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- Path must be unique within the site
|
||||||
|
- Config is a flexible JSON object
|
||||||
|
|
||||||
|
### Update Page
|
||||||
|
|
||||||
|
Update an existing publication page.
|
||||||
|
|
||||||
|
**Endpoint**: `PATCH /api/sites/pages/{id}`
|
||||||
|
|
||||||
|
**Authorization**: Required (Editor role or higher in publisher)
|
||||||
|
|
||||||
|
**Request Body**: Same as Create Page, all fields optional except id.
|
||||||
|
|
||||||
|
**Response**: `200 OK` - Returns updated page object.
|
||||||
|
|
||||||
|
### Delete Page
|
||||||
|
|
||||||
|
Delete a publication page.
|
||||||
|
|
||||||
|
**Endpoint**: `DELETE /api/sites/pages/{id}`
|
||||||
|
|
||||||
|
**Authorization**: Required (Editor role or higher in publisher)
|
||||||
|
|
||||||
|
**Response**: `204 No Content`
|
||||||
|
|
||||||
|
## File Management Endpoints
|
||||||
|
|
||||||
|
### List Files
|
||||||
|
|
||||||
|
Get a list of files and directories in a site directory.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/{siteId}/files`
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `path` (optional): Relative path to the directory. Defaults to root directory.
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"is_directory": true,
|
||||||
|
"relative_path": "folder1",
|
||||||
|
"size": 0,
|
||||||
|
"modified": "2024-01-15T10:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_directory": false,
|
||||||
|
"relative_path": "index.html",
|
||||||
|
"size": 1024,
|
||||||
|
"modified": "2024-01-15T09:15:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload File
|
||||||
|
|
||||||
|
Upload a new file to the site.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/sites/{siteId}/files/upload`
|
||||||
|
|
||||||
|
**Content-Type**: `multipart/form-data`
|
||||||
|
|
||||||
|
**Form Data**:
|
||||||
|
- `filePath`: Relative path where the file should be stored (including filename)
|
||||||
|
- `file`: The file to upload
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- File must be provided and not empty
|
||||||
|
- File size must not exceed 1 MB
|
||||||
|
- Total site size must not exceed 25 MB after upload
|
||||||
|
|
||||||
|
### Get File Content
|
||||||
|
|
||||||
|
Retrieve the text content of a file.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/{siteId}/files/content/{relativePath}`
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "<!DOCTYPE html>\n<html>\n<body>Hello World</body>\n</html>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supported file types**: Primarily text-based files. Returns raw text content.
|
||||||
|
|
||||||
|
### Download File
|
||||||
|
|
||||||
|
Download a file with proper MIME type headers.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sites/{siteId}/files/download/{relativePath}`
|
||||||
|
|
||||||
|
**Response**: `200 OK` with file content
|
||||||
|
|
||||||
|
**MIME Types**:
|
||||||
|
- `.txt` → `text/plain`
|
||||||
|
- `.html`, `.htm` → `text/html`
|
||||||
|
- `.css` → `text/css`
|
||||||
|
- `.js` → `application/javascript`
|
||||||
|
- `.json` → `application/json`
|
||||||
|
- Others → `application/octet-stream`
|
||||||
|
|
||||||
|
File is returned as attachment with the original filename.
|
||||||
|
|
||||||
|
### Update File Content
|
||||||
|
|
||||||
|
Update the content of an existing text file.
|
||||||
|
|
||||||
|
**Endpoint**: `PUT /api/sites/{siteId}/files/edit/{relativePath}`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"new_content": "<!DOCTYPE html>\n<html>\n<body>Updated content</body>\n</html>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- New content size must not exceed 1 MB
|
||||||
|
- Total site size must not exceed 25 MB after update
|
||||||
|
|
||||||
|
### Delete File
|
||||||
|
|
||||||
|
Delete a file or empty directory.
|
||||||
|
|
||||||
|
**Endpoint**: `DELETE /api/sites/{siteId}/files/delete/{relativePath}`
|
||||||
|
|
||||||
|
**Response**: `200 OK`
|
||||||
|
|
||||||
|
**Note**: Deletes both files and directories. Directories must be empty to be deleted.
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
### 401 Unauthorized
|
||||||
|
User is not authenticated.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 401,
|
||||||
|
"message": "Unauthorized"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 403 Forbidden
|
||||||
|
User does not have required permissions.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 403,
|
||||||
|
"message": "Forbidden"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 404 Not Found
|
||||||
|
- Site not found or not in SelfManaged mode
|
||||||
|
- File or directory not found
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 404,
|
||||||
|
"message": "Site not found or not self-managed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 400 Bad Request
|
||||||
|
Various validation errors including:
|
||||||
|
- File size limits exceeded
|
||||||
|
- Total site size limits exceeded
|
||||||
|
- Invalid file path
|
||||||
|
- Missing required parameters
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "File size exceeds 1MB limit"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Upload a file using curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://api.dyson.network/api/sites/123e4567-e89b-12d3-a456-426614174000/files/upload" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-F "filePath=index.html" \
|
||||||
|
-F "file=@./index.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update file content using curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT "https://api.dyson.network/api/sites/123e4567-e89b-12d3-a456-426614174000/files/edit/index.html" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"new_content": "<!DOCTYPE html><html><body>Hello World</body></html>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List files in a subdirectory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.dyson.network/api/sites/123e4567-e89b-12d3-a456-426614174000/files?path=assets/css" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download a file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.dyson.network/api/sites/123e4567-e89b-12d3-a456-426614174000/files/download/index.html" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-o downloaded_index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### PublicationSite
|
||||||
|
Represents a publication site.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublicationSite {
|
||||||
|
id: string; // GUID
|
||||||
|
slug: string; // Unique within publisher, max 4096 chars
|
||||||
|
name: string; // Display name, max 4096 chars
|
||||||
|
description?: string; // Optional description, max 8192 chars
|
||||||
|
mode: "FullyManaged" | "SelfManaged";
|
||||||
|
pages: PublicationPage[];
|
||||||
|
publisher_id: string; // GUID
|
||||||
|
account_id: string; // GUID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PublicationPage
|
||||||
|
Represents a page within a publication site.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublicationPage {
|
||||||
|
id: string; // GUID
|
||||||
|
type: "HtmlPage" | "Redirect";
|
||||||
|
path: string; // Page path within site, max 8192 chars
|
||||||
|
config: { [key: string]: any }; // Flexible JSON configuration
|
||||||
|
site_id: string; // GUID of parent site
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PublicationSiteRequest
|
||||||
|
Used for creating/updating sites.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublicationSiteRequest {
|
||||||
|
mode: "FullyManaged" | "SelfManaged";
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PublicationPageRequest
|
||||||
|
Used for creating/updating pages.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublicationPageRequest {
|
||||||
|
type: "HtmlPage" | "Redirect";
|
||||||
|
path?: string;
|
||||||
|
config?: { [key: string]: any };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FileEntry
|
||||||
|
Represents a file or directory entry.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FileEntry {
|
||||||
|
is_directory: boolean;
|
||||||
|
relative_path: string;
|
||||||
|
size: number; // Size in bytes (0 for directories)
|
||||||
|
modified: string; // ISO 8601 timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UpdateFileRequest
|
||||||
|
Used for updating file content.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UpdateFileRequest {
|
||||||
|
new_content: string; // The new content for the file
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- All file operations are restricted to the authenticated user's authorized sites
|
||||||
|
- Path traversal attacks are prevented through path validation
|
||||||
|
- Size limits prevent abuse of storage resources
|
||||||
|
- Files are served from a dedicated web root directory to prevent access to sensitive system files
|
||||||
@@ -94,9 +94,9 @@ public class PublicationSiteController(
|
|||||||
return Ok(site);
|
return Ok(site);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{pubName}/{id:guid}")]
|
[HttpPatch("{pubName}/{slug}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnPublicationSite>> UpdateSite([FromRoute] string pubName, Guid id, [FromBody] PublicationSiteRequest request)
|
public async Task<ActionResult<SnPublicationSite>> UpdateSite([FromRoute] string pubName, string slug, [FromBody] PublicationSiteRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
@@ -105,7 +105,7 @@ public class PublicationSiteController(
|
|||||||
var publisher = await publisherService.GetPublisherByName(pubName);
|
var publisher = await publisherService.GetPublisherByName(pubName);
|
||||||
if (publisher == null) return NotFound();
|
if (publisher == null) return NotFound();
|
||||||
|
|
||||||
var site = await publicationService.GetSiteById(id);
|
var site = await publicationService.GetSiteBySlug(slug, pubName);
|
||||||
if (site == null || site.PublisherId != publisher.Id)
|
if (site == null || site.PublisherId != publisher.Id)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
@@ -126,9 +126,9 @@ public class PublicationSiteController(
|
|||||||
return Ok(site);
|
return Ok(site);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{pubName}/{id:guid}")]
|
[HttpDelete("{pubName}/{slug}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> DeleteSite([FromRoute] string pubName, Guid id)
|
public async Task<IActionResult> DeleteSite([FromRoute] string pubName, string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
@@ -137,13 +137,13 @@ public class PublicationSiteController(
|
|||||||
var publisher = await publisherService.GetPublisherByName(pubName);
|
var publisher = await publisherService.GetPublisherByName(pubName);
|
||||||
if (publisher == null) return NotFound();
|
if (publisher == null) return NotFound();
|
||||||
|
|
||||||
var site = await publicationService.GetSiteById(id);
|
var site = await publicationService.GetSiteBySlug(slug, pubName);
|
||||||
if (site == null || site.PublisherId != publisher.Id)
|
if (site == null || site.PublisherId != publisher.Id)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await publicationService.DeleteSite(id, accountId);
|
await publicationService.DeleteSite(site.Id, accountId);
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException)
|
catch (UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
|
|||||||
139
DysonNetwork.Zone/Publication/PublicationSiteManager.cs
Normal file
139
DysonNetwork.Zone/Publication/PublicationSiteManager.cs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Zone.Publication;
|
||||||
|
|
||||||
|
public class FileEntry
|
||||||
|
{
|
||||||
|
public bool IsDirectory { get; set; }
|
||||||
|
public string RelativePath { get; set; } = null!;
|
||||||
|
public long Size { get; set; }
|
||||||
|
public DateTimeOffset Modified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublicationSiteManager(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment hostEnvironment,
|
||||||
|
PublicationSiteService publicationSiteService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly string _basePath = Path.Combine(
|
||||||
|
hostEnvironment.WebRootPath,
|
||||||
|
configuration["Sites:BasePath"]!.TrimStart('/')
|
||||||
|
);
|
||||||
|
|
||||||
|
private string GetFullPath(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
// Treat paths starting with separator as relative to site root
|
||||||
|
relativePath = relativePath.TrimStart('/', '\\');
|
||||||
|
string fullPath = Path.Combine(_basePath, siteId.ToString(), relativePath);
|
||||||
|
string normalizedPath = Path.GetFullPath(fullPath);
|
||||||
|
string siteDirFull = Path.Combine(_basePath, siteId.ToString());
|
||||||
|
string normalizedSiteDir = Path.GetFullPath(siteDirFull);
|
||||||
|
if (!normalizedPath.StartsWith(normalizedSiteDir + Path.DirectorySeparatorChar) &&
|
||||||
|
!normalizedPath.Equals(normalizedSiteDir))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Path escapes site directory");
|
||||||
|
}
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureSiteDirectory(Guid siteId)
|
||||||
|
{
|
||||||
|
var site = await publicationSiteService.GetSiteById(siteId);
|
||||||
|
if (site is not { Mode: PublicationSiteMode.SelfManaged })
|
||||||
|
throw new InvalidOperationException("Site not found or not self-managed");
|
||||||
|
var dir = Path.Combine(_basePath, siteId.ToString());
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FileEntry>> ListFiles(Guid siteId, string relativePath = "")
|
||||||
|
{
|
||||||
|
await EnsureSiteDirectory(siteId);
|
||||||
|
var targetDir = GetFullPath(siteId, relativePath);
|
||||||
|
if (!Directory.Exists(targetDir))
|
||||||
|
throw new DirectoryNotFoundException("Directory not found");
|
||||||
|
|
||||||
|
var entries = (from file in Directory.GetFiles(targetDir)
|
||||||
|
let fileInfo = new FileInfo(file)
|
||||||
|
select new FileEntry
|
||||||
|
{
|
||||||
|
IsDirectory = false,
|
||||||
|
RelativePath = Path.GetRelativePath(Path.Combine(_basePath, siteId.ToString()), file),
|
||||||
|
Size = fileInfo.Length, Modified = fileInfo.LastWriteTimeUtc
|
||||||
|
}).ToList();
|
||||||
|
entries.AddRange(from subDir in Directory.GetDirectories(targetDir)
|
||||||
|
let dirInfo = new DirectoryInfo(subDir)
|
||||||
|
select new FileEntry
|
||||||
|
{
|
||||||
|
IsDirectory = true,
|
||||||
|
RelativePath = Path.GetRelativePath(Path.Combine(_basePath, siteId.ToString()), subDir),
|
||||||
|
Size = 0, // Directories don't have size
|
||||||
|
Modified = dirInfo.LastWriteTimeUtc
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UploadFile(Guid siteId, string relativePath, IFormFile file)
|
||||||
|
{
|
||||||
|
await EnsureSiteDirectory(siteId);
|
||||||
|
var fullPath = GetFullPath(siteId, relativePath);
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(fullPath);
|
||||||
|
if (dir != null && !Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
await using var stream = new FileStream(fullPath, FileMode.Create);
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ReadFileContent(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
await EnsureSiteDirectory(siteId);
|
||||||
|
var fullPath = GetFullPath(siteId, relativePath);
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
throw new FileNotFoundException();
|
||||||
|
return await File.ReadAllTextAsync(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetTotalSiteSize(Guid siteId)
|
||||||
|
{
|
||||||
|
await EnsureSiteDirectory(siteId);
|
||||||
|
var dir = new DirectoryInfo(Path.Combine(_basePath, siteId.ToString()));
|
||||||
|
return GetDirectorySize(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetDirectorySize(DirectoryInfo dir)
|
||||||
|
{
|
||||||
|
var files = dir.GetFiles();
|
||||||
|
var size = files.Sum(file => file.Length);
|
||||||
|
|
||||||
|
var subDirs = dir.GetDirectories();
|
||||||
|
size += subDirs.Sum(GetDirectorySize);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetValidatedFullPath(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
return GetFullPath(siteId, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateFile(Guid siteId, string relativePath, string newContent)
|
||||||
|
{
|
||||||
|
await EnsureSiteDirectory(siteId);
|
||||||
|
var fullPath = GetFullPath(siteId, relativePath);
|
||||||
|
await File.WriteAllTextAsync(fullPath, newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteFile(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
await EnsureSiteDirectory(siteId);
|
||||||
|
var fullPath = GetFullPath(siteId, relativePath);
|
||||||
|
if (File.Exists(fullPath))
|
||||||
|
File.Delete(fullPath);
|
||||||
|
else if (Directory.Exists(fullPath))
|
||||||
|
Directory.Delete(fullPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
DysonNetwork.Zone/Publication/SiteManagerController.cs
Normal file
198
DysonNetwork.Zone/Publication/SiteManagerController.cs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Zone.Publication;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/sites/{siteId:guid}/files")]
|
||||||
|
public class SiteManagerController(
|
||||||
|
PublicationSiteService publicationSiteService,
|
||||||
|
PublicationSiteManager fileManager,
|
||||||
|
RemotePublisherService remotePublisherService
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
private async Task<ActionResult?> CheckAccess(Guid siteId)
|
||||||
|
{
|
||||||
|
var site = await publicationSiteService.GetSiteById(siteId);
|
||||||
|
if (site is not { Mode: PublicationSiteMode.SelfManaged })
|
||||||
|
return NotFound("Site not found or not self-managed");
|
||||||
|
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var isMember = await remotePublisherService.IsMemberWithRole(site.PublisherId, accountId, PublisherMemberRole.Editor);
|
||||||
|
return !isMember ? Forbid() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<FileEntry>>> ListFiles(Guid siteId, [FromQuery] string path = "")
|
||||||
|
{
|
||||||
|
var check = await CheckAccess(siteId);
|
||||||
|
if (check != null) return check;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await fileManager.ListFiles(siteId, path);
|
||||||
|
return Ok(entries);
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
return NotFound("Directory not found");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("upload")]
|
||||||
|
[Authorize]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<IActionResult> UploadFile(Guid siteId, [FromForm] string filePath, IFormFile? file)
|
||||||
|
{
|
||||||
|
var check = await CheckAccess(siteId);
|
||||||
|
if (check != null) return check;
|
||||||
|
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return BadRequest("No file provided");
|
||||||
|
|
||||||
|
const long maxFileSize = 1048576; // 1MB
|
||||||
|
const long maxTotalSize = 26214400; // 25MB
|
||||||
|
|
||||||
|
if (file.Length > maxFileSize)
|
||||||
|
return BadRequest("File size exceeds 1MB limit");
|
||||||
|
|
||||||
|
var currentTotal = await fileManager.GetTotalSiteSize(siteId);
|
||||||
|
if (currentTotal + file.Length > maxTotalSize)
|
||||||
|
return BadRequest("Site total size would exceed 25MB limit");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await fileManager.UploadFile(siteId, filePath, file);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("content/{**relativePath}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<string>> GetFileContent(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
var check = await CheckAccess(siteId);
|
||||||
|
if (check != null) return check;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = await fileManager.ReadFileContent(siteId, relativePath);
|
||||||
|
return Ok(content);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("download/{**relativePath}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> DownloadFile(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
var check = await CheckAccess(siteId);
|
||||||
|
if (check != null) return check;
|
||||||
|
|
||||||
|
string fullPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fullPath = fileManager.GetValidatedFullPath(siteId, relativePath);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return BadRequest("Invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(fullPath))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
var mimeType = "application/octet-stream"; // default
|
||||||
|
var ext = Path.GetExtension(relativePath).ToLowerInvariant();
|
||||||
|
if (ext == ".txt") mimeType = "text/plain";
|
||||||
|
else if (ext == ".html" || ext == ".htm") mimeType = "text/html";
|
||||||
|
else if (ext == ".css") mimeType = "text/css";
|
||||||
|
else if (ext == ".js") mimeType = "application/javascript";
|
||||||
|
else if (ext == ".json") mimeType = "application/json";
|
||||||
|
|
||||||
|
return PhysicalFile(fullPath, mimeType, Path.GetFileName(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("edit/{**relativePath}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> UpdateFile(Guid siteId, string relativePath, [FromBody] UpdateFileRequest request)
|
||||||
|
{
|
||||||
|
var check = await CheckAccess(siteId);
|
||||||
|
if (check != null) return check;
|
||||||
|
|
||||||
|
const long maxFileSize = 1048576; // 1MB
|
||||||
|
const long maxTotalSize = 26214400; // 25MB
|
||||||
|
|
||||||
|
if (request.NewContent.Length > maxFileSize)
|
||||||
|
return BadRequest("New content exceeds 1MB limit");
|
||||||
|
|
||||||
|
long oldSize = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPath = fileManager.GetValidatedFullPath(siteId, relativePath);
|
||||||
|
if (System.IO.File.Exists(fullPath))
|
||||||
|
oldSize = new FileInfo(fullPath).Length;
|
||||||
|
|
||||||
|
var currentTotal = await fileManager.GetTotalSiteSize(siteId);
|
||||||
|
if (currentTotal - oldSize + request.NewContent.Length > maxTotalSize)
|
||||||
|
return BadRequest("Site total size would exceed 25MB limit");
|
||||||
|
|
||||||
|
await fileManager.UpdateFile(siteId, relativePath, request.NewContent);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return BadRequest("Invalid path");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("delete/{**relativePath}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> DeleteFile(Guid siteId, string relativePath)
|
||||||
|
{
|
||||||
|
var check = await CheckAccess(siteId);
|
||||||
|
if (check != null) return check;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await fileManager.DeleteFile(siteId, relativePath);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateFileRequest
|
||||||
|
{
|
||||||
|
public string NewContent { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<GeoIpService>();
|
services.AddScoped<GeoIpService>();
|
||||||
|
|
||||||
services.AddScoped<PublicationSiteService>();
|
services.AddScoped<PublicationSiteService>();
|
||||||
|
services.AddScoped<PublicationSiteManager>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user