bud 08 implemented

This commit is contained in:
Your Name
2025-09-03 14:41:55 -04:00
parent d845f7822f
commit 17bb57505e
12 changed files with 7610 additions and 147 deletions

58
94.md Normal file
View File

@@ -0,0 +1,58 @@
NIP-94
======
File Metadata
-------------
`draft` `optional`
The purpose of this NIP is to allow an organization and classification of shared files. So that relays can filter and organize in any way that is of interest. With that, multiple types of filesharing clients can be created. NIP-94 support is not expected to be implemented by "social" clients that deal with `kind:1` notes or by longform clients that deal with `kind:30023` articles.
## Event format
This NIP specifies the use of the `1063` event kind, having in `content` a description of the file content, and a list of tags described below:
* `url` the url to download the file
* `m` a string indicating the data type of the file. The [MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) format must be used, and they should be lowercase.
* `x` containing the SHA-256 hexencoded string of the file.
* `ox` containing the SHA-256 hexencoded string of the original file, before any transformations done by the upload server
* `size` (optional) size of file in bytes
* `dim` (optional) size of file in pixels in the form `<width>x<height>`
* `magnet` (optional) URI to magnet file
* `i` (optional) torrent infohash
* `blurhash`(optional) the [blurhash](https://github.com/woltapp/blurhash) to show while the file is being loaded by the client
* `thumb` (optional) url of thumbnail with same aspect ratio
* `image` (optional) url of preview image with same dimensions
* `summary` (optional) text excerpt
* `alt` (optional) description for accessibility
* `fallback` (optional) zero or more fallback file sources in case `url` fails
* `service` (optional) service type which is serving the file (eg. [NIP-96](96.md))
```jsonc
{
"kind": 1063,
"tags": [
["url",<string with URI of file>],
["m", <MIME type>],
["x", <Hash SHA-256>],
["ox", <Hash SHA-256>],
["size", <size of file in bytes>],
["dim", <size of file in pixels>],
["magnet", <magnet URI> ],
["i", <torrent infohash>],
["blurhash", <value>],
["thumb", <string with thumbnail URI>, <Hash SHA-256>],
["image", <string with preview URI>, <Hash SHA-256>],
["summary", <excerpt>],
["alt", <description>]
],
"content": "<caption>",
// other fields...
}
```
## Suggested use cases
* A relay for indexing shared files. For example, to promote torrents.
* A pinterest-like client where people can share their portfolio and inspire others.
* A simple way to distribute configurations and software updates.

View File

@@ -175,14 +175,46 @@ This document tracks the implementation status of ginxsom, a high-performance Fa
--- ---
## BUD-08: NIP-94 Metadata ⚪ **NOT IMPLEMENTED** ## BUD-08: NIP-94 File Metadata Tags ✅ **COMPLETE**
*Optional feature - not currently planned* ### NIP-94 Integration
- [x] Configuration system with SQLite server_config table
- [x] `nip94_enabled` configuration key (default: true)
- [x] `cdn_origin` configuration key (default: "http://localhost:9001")
- [x] Centralized MIME type to extension mapping [`mime_to_extension()`](src/main.c:2024)
- [x] Canonical blob URL generation [`nip94_build_blob_url()`](src/main.c:2055)
- [x] NIP-94 metadata field emission [`nip94_emit_field()`](src/main.c:2201)
- [ ] NIP-94 tag generation ### Image Dimension Detection
- [ ] Extended blob descriptor responses - [x] PNG dimension parsing from IHDR chunk [`parse_png_dimensions()`](src/main.c:2065)
- [ ] Magnet link generation - [x] JPEG dimension parsing from SOF0/SOF2 markers [`parse_jpeg_dimensions()`](src/main.c:2089)
- [ ] Metadata compatibility testing - [x] WebP dimension parsing for VP8/VP8L/VP8X formats [`parse_webp_dimensions()`](src/main.c:2141)
- [x] Universal dimension dispatcher [`nip94_get_dimensions()`](src/main.c:2184)
### Integration Points
- [x] PUT /upload endpoint enhanced with NIP-94 metadata
- [x] PUT /mirror endpoint enhanced with NIP-94 metadata
- [x] Configuration-driven origin override for CDN support
- [x] Conditional metadata emission based on `nip94_enabled` setting
- [x] Proper JSON comma handling for optional nip94 field
### NIP-94 Tags Implemented
- [x] `url` tag - Canonical blob URL with proper origin and extension
- [x] `m` tag - MIME type (Content-Type)
- [x] `x` tag - SHA-256 hash (lowercase hex)
- [x] `size` tag - File size in bytes
- [x] `dim` tag - Image dimensions (e.g., "1x1", "1920x1080") when available
### Configuration Keys
- `nip94_enabled` (boolean): Enable/disable NIP-94 metadata emission (default: true)
- `cdn_origin` (string): Base URL for blob URLs (default: "http://localhost:9001")
### Testing Status
- [x] NIP-94 minimal tags (url, m, x, size) present in responses
- [x] Image dimension detection working for PNG 1x1 test case
- [x] Configuration-based enable/disable functionality
- [x] CDN origin override affecting both descriptor and nip94 URLs
- [x] Mirror endpoint NIP-94 integration (network-dependent)
--- ---

View File

@@ -4,9 +4,9 @@ A high-performance [Blossom](https://github.com/hzrd149/blossom) server implemen
## Overview ## Overview
ginxsom is a Blossom protocol server implemented as a FastCGI application that integrates seamlessly with nginx. nginx handles static file serving directly while ginxsom processes authenticated operations (uploads, deletes, management) via FastCGI. This architecture provides optimal performance with nginx's excellent static file serving and C's efficiency for cryptographic operations. Ginxsom is a Blossom protocol server implemented as a FastCGI application that integrates seamlessly with nginx. Nginx handles static file serving directly while ginxsom processes authenticated operations (uploads, deletes, management) via FastCGI. This architecture provides optimal performance with nginx's excellent static file serving and C's efficiency for cryptographic operations.
### Why ginxsom? ### Why Ginxsom?
- **Performance**: C application with nginx static serving = maximum throughput - **Performance**: C application with nginx static serving = maximum throughput
- **Simplicity**: Clean separation between static serving (nginx) and dynamic operations (C app) - **Simplicity**: Clean separation between static serving (nginx) and dynamic operations (C app)
@@ -40,9 +40,14 @@ ginxsom is a Blossom protocol server implemented as a FastCGI application that i
ginxsom implements the following Blossom Upgrade Documents (BUDs): ginxsom implements the following Blossom Upgrade Documents (BUDs):
- **BUD-01**: Server requirements and blob retrieval - [x] **BUD-01**: Server requirements and blob retrieval
- **BUD-02**: Blob upload and management*(newly completed - includes DELETE endpoint)* - [x] **BUD-02**: Blob upload and management
- **BUD-06**: Upload requirements ⏳ *(planned - not yet implemented)* - [x] **BUD-04**: Blob Mirroring
- [ ] **BUD-05**: Media Optimization *(Partial)*
- [x] **BUD-06**: Upload Requirements
- [ ] **BUD-07**: Payment Integration *(Not implemented)*
- [x] **BUD-08**: NIP-94 File Metadata Tags
- [ ] **BUD-09**: Content Reporting *(Partial)*
### Supported Endpoints ### Supported Endpoints
@@ -55,39 +60,6 @@ ginxsom implements the following Blossom Upgrade Documents (BUDs):
| `/list/<pubkey>` | GET | List user's blobs | nginx → FastCGI ginxsom | ✅ **Implemented** | | `/list/<pubkey>` | GET | List user's blobs | nginx → FastCGI ginxsom | ✅ **Implemented** |
| `/<sha256>` | DELETE | Delete blob | nginx → FastCGI ginxsom | ✅ **Recently Added** | | `/<sha256>` | DELETE | Delete blob | nginx → FastCGI ginxsom | ✅ **Recently Added** |
## Recent Updates
### BUD-02 Completion: DELETE Endpoint Implementation
ginxsom now fully implements **BUD-02: Blob upload and management** with the recent addition of the DELETE endpoint. This completes the core blob management functionality:
**New DELETE Endpoint Features:**
- **Authenticated Deletion**: Requires valid nostr kind 24242 event with `t` tag set to `"delete"`
- **Hash Validation**: Must include `x` tag matching the blob's SHA-256 hash
- **Ownership Verification**: Only the original uploader can delete their blobs
- **Complete Cleanup**: Removes both file from disk and metadata from database
- **Error Handling**: Proper HTTP status codes for various failure scenarios
**Technical Implementation:**
```bash
# Delete a blob (requires nostr authorization)
curl -X DELETE http://localhost:9001/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553 \
-H "Authorization: Nostr eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
# Successful deletion returns 200 OK
# Failed authorization returns 401 Unauthorized
# Blob not found returns 404 Not Found
# Wrong ownership returns 403 Forbidden
```
**Security Features:**
- Event signature validation using nostr cryptographic verification
- Expiration checking to prevent replay attacks
- Ownership validation via uploader_pubkey matching
- Atomic operations (both filesystem and database cleanup succeed or fail together)
This implementation makes ginxsom a fully functional Blossom server for core blob operations (upload, retrieve, list, delete) with the remaining BUD-06 (upload requirements) planned for the next development phase.
## Installation ## Installation
### Prerequisites ### Prerequisites
@@ -302,7 +274,7 @@ The nostr event must be kind `24242` with appropriate tags:
### Blob Descriptors ### Blob Descriptors
Successful uploads return blob descriptors: Successful uploads return blob descriptors with optional NIP-94 metadata:
```json ```json
{ {
@@ -310,10 +282,25 @@ Successful uploads return blob descriptors:
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553", "sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"size": 184292, "size": 184292,
"type": "application/pdf", "type": "application/pdf",
"uploaded": 1725105921 "uploaded": 1725105921,
"nip94": [
["url", "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"],
["m", "application/pdf"],
["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"],
["size", "184292"]
]
} }
``` ```
**NIP-94 Tags:**
- `url`: Canonical blob URL with proper CDN origin
- `m`: MIME type (Content-Type)
- `x`: SHA-256 hash (lowercase hex)
- `size`: File size in bytes
- `dim`: Image dimensions (when available, e.g., "1920x1080")
The `nip94` field is included by default but can be disabled via server configuration.
## File Storage ## File Storage
### Current (Flat) Structure ### Current (Flat) Structure

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -150,3 +150,23 @@
127.0.0.1 - - [03/Sep/2025:13:08:36 -0400] "HEAD /upload HTTP/1.1" 400 0 "-" "curl/8.15.0" 127.0.0.1 - - [03/Sep/2025:13:08:36 -0400] "HEAD /upload HTTP/1.1" 400 0 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:13:08:36 -0400] "HEAD /upload HTTP/1.1" 200 0 "-" "curl/8.15.0" 127.0.0.1 - - [03/Sep/2025:13:08:36 -0400] "HEAD /upload HTTP/1.1" 200 0 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:13:08:36 -0400] "HEAD /upload HTTP/1.1" 200 0 "-" "curl/8.15.0" 127.0.0.1 - - [03/Sep/2025:13:08:36 -0400] "HEAD /upload HTTP/1.1" 200 0 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:13:41:17 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:13:41:17 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:13:41:17 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:13:41:18 -0400] "PUT /mirror HTTP/1.1" 200 257 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:06 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:06 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:06 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:07 -0400] "PUT /mirror HTTP/1.1" 200 535 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:25 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:25 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:25 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:25:26 -0400] "PUT /mirror HTTP/1.1" 200 535 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:26:18 -0400] "PUT /upload HTTP/1.1" 200 528 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:26:18 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:26:18 -0400] "PUT /upload HTTP/1.1" 200 534 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:26:19 -0400] "PUT /mirror HTTP/1.1" 200 535 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:29:56 -0400] "PUT /upload HTTP/1.1" 200 528 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:29:56 -0400] "PUT /upload HTTP/1.1" 200 260 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:29:56 -0400] "PUT /upload HTTP/1.1" 200 534 "-" "curl/8.15.0"
127.0.0.1 - - [03/Sep/2025:14:29:57 -0400] "PUT /mirror HTTP/1.1" 200 535 "-" "curl/8.15.0"

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
FastCGI starting at Wed Sep 3 12:52:46 PM EDT 2025 FastCGI starting at Wed Sep 3 02:29:47 PM EDT 2025

View File

@@ -1 +1 @@
221465 244696

View File

@@ -51,6 +51,17 @@ int check_blob_exists(const char* sha256);
int validate_upload_headers(const char** sha256, const char** content_type, long* content_length, char* error_reason, size_t reason_size); int validate_upload_headers(const char** sha256, const char** content_type, long* content_length, char* error_reason, size_t reason_size);
void handle_head_upload_request(void); void handle_head_upload_request(void);
// BUD-08 NIP-94 function declarations
int nip94_is_enabled(void);
int nip94_get_origin(char* out, size_t out_size);
const char* mime_to_extension(const char* mime_type);
void nip94_build_blob_url(const char* origin, const char* sha256, const char* mime_type, char* out, size_t out_size);
int parse_png_dimensions(const unsigned char* data, size_t size, int* width, int* height);
int parse_jpeg_dimensions(const unsigned char* data, size_t size, int* width, int* height);
int parse_webp_dimensions(const unsigned char* data, size_t size, int* width, int* height);
int nip94_get_dimensions(const unsigned char* data, size_t size, const char* mime_type, int* width, int* height);
void nip94_emit_field(const char* url, const char* mime, const char* sha256, long size, int width, int height);
// Blob metadata structure // Blob metadata structure
typedef struct { typedef struct {
char sha256[MAX_SHA256_LEN]; char sha256[MAX_SHA256_LEN];
@@ -170,28 +181,7 @@ int get_blob_metadata(const char* sha256, blob_metadata_t* metadata) {
// Check if physical file exists (with extension based on MIME type) // Check if physical file exists (with extension based on MIME type)
int file_exists_with_type(const char* sha256, const char* mime_type) { int file_exists_with_type(const char* sha256, const char* mime_type) {
char filepath[MAX_PATH_LEN]; char filepath[MAX_PATH_LEN];
const char* extension = ""; const char* extension = mime_to_extension(mime_type);
// Determine file extension based on MIME type
if (strstr(mime_type, "image/jpeg")) {
extension = ".jpg";
} else if (strstr(mime_type, "image/webp")) {
extension = ".webp";
} else if (strstr(mime_type, "image/png")) {
extension = ".png";
} else if (strstr(mime_type, "image/gif")) {
extension = ".gif";
} else if (strstr(mime_type, "video/mp4")) {
extension = ".mp4";
} else if (strstr(mime_type, "video/webm")) {
extension = ".webm";
} else if (strstr(mime_type, "audio/mpeg")) {
extension = ".mp3";
} else if (strstr(mime_type, "audio/ogg")) {
extension = ".ogg";
} else if (strstr(mime_type, "text/plain")) {
extension = ".txt";
}
snprintf(filepath, sizeof(filepath), "blobs/%s%s", sha256, extension); snprintf(filepath, sizeof(filepath), "blobs/%s%s", sha256, extension);
@@ -1635,31 +1625,8 @@ void handle_mirror_request(void) {
const char* content_type_final = determine_blob_content_type(url, download->content_type, const char* content_type_final = determine_blob_content_type(url, download->content_type,
download->data, download->size); download->data, download->size);
// Determine file extension from Content-Type // Determine file extension from Content-Type using centralized mapping
const char* extension = ""; const char* extension = mime_to_extension(content_type_final);
if (strstr(content_type_final, "image/jpeg")) {
extension = ".jpg";
} else if (strstr(content_type_final, "image/webp")) {
extension = ".webp";
} else if (strstr(content_type_final, "image/png")) {
extension = ".png";
} else if (strstr(content_type_final, "image/gif")) {
extension = ".gif";
} else if (strstr(content_type_final, "video/mp4")) {
extension = ".mp4";
} else if (strstr(content_type_final, "video/webm")) {
extension = ".webm";
} else if (strstr(content_type_final, "audio/mpeg")) {
extension = ".mp3";
} else if (strstr(content_type_final, "audio/ogg")) {
extension = ".ogg";
} else if (strstr(content_type_final, "text/plain")) {
extension = ".txt";
} else if (strstr(content_type_final, "application/pdf")) {
extension = ".pdf";
} else {
extension = ".bin";
}
// Save file to blobs directory // Save file to blobs directory
char filepath[MAX_PATH_LEN]; char filepath[MAX_PATH_LEN];
@@ -1704,6 +1671,18 @@ void handle_mirror_request(void) {
return; return;
} }
// Get origin from config
char origin[256];
nip94_get_origin(origin, sizeof(origin));
// Build canonical blob URL
char blob_url[512];
nip94_build_blob_url(origin, sha256_hex, content_type_final, blob_url, sizeof(blob_url));
// Get dimensions for NIP-94 metadata
int width = 0, height = 0;
nip94_get_dimensions(download->data, download->size, content_type_final, &width, &height);
// Return success response with blob descriptor // Return success response with blob descriptor
printf("Status: 200 OK\r\n"); printf("Status: 200 OK\r\n");
printf("Content-Type: application/json\r\n\r\n"); printf("Content-Type: application/json\r\n\r\n");
@@ -1712,8 +1691,15 @@ void handle_mirror_request(void) {
printf(" \"size\": %zu,\n", download->size); printf(" \"size\": %zu,\n", download->size);
printf(" \"type\": \"%s\",\n", content_type_final); printf(" \"type\": \"%s\",\n", content_type_final);
printf(" \"uploaded\": %ld,\n", uploaded_time); printf(" \"uploaded\": %ld,\n", uploaded_time);
printf(" \"url\": \"http://localhost:9001/%s%s\"\n", sha256_hex, extension); printf(" \"url\": \"%s\"", blob_url);
printf("}\n");
// Add NIP-94 metadata if enabled
if (nip94_is_enabled()) {
printf(",\n");
nip94_emit_field(blob_url, content_type_final, sha256_hex, download->size, width, height);
}
printf("\n}\n");
free_mirror_download(download); free_mirror_download(download);
log_request("PUT", "/mirror", uploader_pubkey ? "authenticated" : "anonymous", 200); log_request("PUT", "/mirror", uploader_pubkey ? "authenticated" : "anonymous", 200);
@@ -1970,6 +1956,278 @@ void log_request(const char* method, const char* uri, const char* auth_status, i
auth_status ? auth_status : "none", status_code); auth_status ? auth_status : "none", status_code);
} }
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// BUD 08 - Nip94 File Metadata Tags
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Check if NIP-94 metadata emission is enabled
int nip94_is_enabled(void) {
sqlite3* db;
sqlite3_stmt* stmt;
int rc, enabled = 1; // Default enabled
rc = sqlite3_open_v2(DB_PATH, &db, SQLITE_OPEN_READONLY, NULL);
if (rc) {
return 1; // Default enabled on DB error
}
const char* sql = "SELECT value FROM server_config WHERE key = 'nip94_enabled'";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
const char* value = (const char*)sqlite3_column_text(stmt, 0);
enabled = (value && strcmp(value, "true") == 0) ? 1 : 0;
}
sqlite3_finalize(stmt);
}
sqlite3_close(db);
return enabled;
}
// Get CDN origin for blob URLs
int nip94_get_origin(char* out, size_t out_size) {
if (!out || out_size == 0) {
return 0;
}
sqlite3* db;
sqlite3_stmt* stmt;
int rc;
rc = sqlite3_open_v2(DB_PATH, &db, SQLITE_OPEN_READONLY, NULL);
if (rc) {
// Default on DB error
strncpy(out, "http://localhost:9001", out_size - 1);
out[out_size - 1] = '\0';
return 1;
}
const char* sql = "SELECT value FROM server_config WHERE key = 'cdn_origin'";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
const char* value = (const char*)sqlite3_column_text(stmt, 0);
if (value) {
strncpy(out, value, out_size - 1);
out[out_size - 1] = '\0';
sqlite3_finalize(stmt);
sqlite3_close(db);
return 1;
}
}
sqlite3_finalize(stmt);
}
sqlite3_close(db);
// Default fallback
strncpy(out, "http://localhost:9001", out_size - 1);
out[out_size - 1] = '\0';
return 1;
}
// Centralized MIME type to file extension mapping
const char* mime_to_extension(const char* mime_type) {
if (!mime_type) {
return ".bin";
}
if (strstr(mime_type, "image/jpeg")) {
return ".jpg";
} else if (strstr(mime_type, "image/webp")) {
return ".webp";
} else if (strstr(mime_type, "image/png")) {
return ".png";
} else if (strstr(mime_type, "image/gif")) {
return ".gif";
} else if (strstr(mime_type, "video/mp4")) {
return ".mp4";
} else if (strstr(mime_type, "video/webm")) {
return ".webm";
} else if (strstr(mime_type, "audio/mpeg")) {
return ".mp3";
} else if (strstr(mime_type, "audio/ogg")) {
return ".ogg";
} else if (strstr(mime_type, "text/plain")) {
return ".txt";
} else if (strstr(mime_type, "application/pdf")) {
return ".pdf";
} else {
return ".bin";
}
}
// Build canonical blob URL from origin + sha256 + extension
void nip94_build_blob_url(const char* origin, const char* sha256, const char* mime_type, char* out, size_t out_size) {
if (!origin || !sha256 || !out || out_size == 0) {
return;
}
const char* extension = mime_to_extension(mime_type);
snprintf(out, out_size, "%s/%s%s", origin, sha256, extension);
}
// Parse PNG dimensions from IHDR chunk
int parse_png_dimensions(const unsigned char* data, size_t size, int* width, int* height) {
if (!data || size < 24 || !width || !height) {
return 0;
}
// Verify PNG signature
if (memcmp(data, "\x89PNG\r\n\x1a\n", 8) != 0) {
return 0;
}
// IHDR chunk should start at offset 8
// Skip chunk length (4 bytes) and chunk type "IHDR" (4 bytes)
// Width is at offset 16 (4 bytes, big-endian)
// Height is at offset 20 (4 bytes, big-endian)
if (size >= 24) {
*width = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19];
*height = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23];
return 1;
}
return 0;
}
// Parse JPEG dimensions from SOF0 or SOF2 markers
int parse_jpeg_dimensions(const unsigned char* data, size_t size, int* width, int* height) {
if (!data || size < 10 || !width || !height) {
return 0;
}
// Verify JPEG signature
if (size < 3 || memcmp(data, "\xff\xd8\xff", 3) != 0) {
return 0;
}
size_t pos = 2;
while (pos < size - 1) {
// Look for marker
if (data[pos] != 0xff) {
pos++;
continue;
}
unsigned char marker = data[pos + 1];
pos += 2;
// SOF0 (0xc0) or SOF2 (0xc2)
if (marker == 0xc0 || marker == 0xc2) {
// Skip length (2 bytes) and precision (1 byte)
if (pos + 5 < size) {
pos += 3;
// Height (2 bytes, big-endian)
*height = (data[pos] << 8) | data[pos + 1];
pos += 2;
// Width (2 bytes, big-endian)
*width = (data[pos] << 8) | data[pos + 1];
return 1;
}
return 0;
} else if ((marker >= 0xe0 && marker <= 0xef) ||
(marker >= 0xc4 && marker <= 0xdf && marker != 0xc8)) {
// Skip over other segments
if (pos + 1 < size) {
size_t seg_len = (data[pos] << 8) | data[pos + 1];
pos += seg_len;
} else {
break;
}
} else {
pos++;
}
}
return 0;
}
// Parse WebP dimensions from VP8/VP8L/VP8X chunks
int parse_webp_dimensions(const unsigned char* data, size_t size, int* width, int* height) {
if (!data || size < 20 || !width || !height) {
return 0;
}
// Verify RIFF/WEBP header
if (memcmp(data, "RIFF", 4) != 0 || memcmp(data + 8, "WEBP", 4) != 0) {
return 0;
}
// Check chunk type at offset 12
if (memcmp(data + 12, "VP8 ", 4) == 0) {
// VP8 lossy format
if (size >= 30) {
// Skip to frame header (offset 26)
*width = ((data[28] | (data[29] << 8)) & 0x3fff);
*height = ((data[26] | (data[27] << 8)) & 0x3fff);
return 1;
}
} else if (memcmp(data + 12, "VP8L", 4) == 0) {
// VP8L lossless format
if (size >= 25) {
// Width and height are in bits 0-13 and 14-27 of a 32-bit value at offset 21
uint32_t dim_data = data[21] | (data[22] << 8) | (data[23] << 16) | (data[24] << 24);
*width = (dim_data & 0x3fff) + 1;
*height = ((dim_data >> 14) & 0x3fff) + 1;
return 1;
}
} else if (memcmp(data + 12, "VP8X", 4) == 0) {
// VP8X extended format
if (size >= 30) {
// Width (3 bytes, little-endian) at offset 24
// Height (3 bytes, little-endian) at offset 27
*width = (data[24] | (data[25] << 8) | (data[26] << 16)) + 1;
*height = (data[27] | (data[28] << 8) | (data[29] << 16)) + 1;
return 1;
}
}
return 0;
}
// Get file dimensions based on MIME type
int nip94_get_dimensions(const unsigned char* data, size_t size, const char* mime_type, int* width, int* height) {
if (!data || !mime_type || !width || !height) {
return 0;
}
if (strstr(mime_type, "image/png")) {
return parse_png_dimensions(data, size, width, height);
} else if (strstr(mime_type, "image/jpeg")) {
return parse_jpeg_dimensions(data, size, width, height);
} else if (strstr(mime_type, "image/webp")) {
return parse_webp_dimensions(data, size, width, height);
}
return 0;
}
// Emit NIP-94 metadata field to stdout
void nip94_emit_field(const char* url, const char* mime, const char* sha256, long size, int width, int height) {
if (!url || !mime || !sha256) {
return;
}
printf(" \"nip94\": [\n");
printf(" [\"url\", \"%s\"],\n", url);
printf(" [\"m\", \"%s\"],\n", mime);
printf(" [\"x\", \"%s\"],\n", sha256);
printf(" [\"size\", \"%ld\"]", size);
// Add dim tag if dimensions are available
if (width > 0 && height > 0) {
printf(",\n [\"dim\", \"%dx%d\"]", width, height);
}
printf("\n ]");
}
// Handle GET /list/<pubkey> requests // Handle GET /list/<pubkey> requests
void handle_list_request(const char* pubkey) { void handle_list_request(const char* pubkey) {
@@ -2102,33 +2360,17 @@ void handle_list_request(const char* pubkey) {
long uploaded_at = sqlite3_column_int64(stmt, 3); long uploaded_at = sqlite3_column_int64(stmt, 3);
const char* filename = (const char*)sqlite3_column_text(stmt, 4); const char* filename = (const char*)sqlite3_column_text(stmt, 4);
// Determine file extension from MIME type // Get origin from config for consistent URL generation
const char* extension = ""; char origin[256];
if (strstr(type, "image/jpeg")) { nip94_get_origin(origin, sizeof(origin));
extension = ".jpg";
} else if (strstr(type, "image/webp")) { // Build canonical blob URL using centralized function
extension = ".webp"; char blob_url[512];
} else if (strstr(type, "image/png")) { nip94_build_blob_url(origin, sha256, type, blob_url, sizeof(blob_url));
extension = ".png";
} else if (strstr(type, "image/gif")) {
extension = ".gif";
} else if (strstr(type, "video/mp4")) {
extension = ".mp4";
} else if (strstr(type, "video/webm")) {
extension = ".webm";
} else if (strstr(type, "audio/mpeg")) {
extension = ".mp3";
} else if (strstr(type, "audio/ogg")) {
extension = ".ogg";
} else if (strstr(type, "text/plain")) {
extension = ".txt";
} else {
extension = ".bin";
}
// Output blob descriptor JSON // Output blob descriptor JSON
printf(" {\n"); printf(" {\n");
printf(" \"url\": \"http://localhost:9001/%s%s\",\n", sha256, extension); printf(" \"url\": \"%s\",\n", blob_url);
printf(" \"sha256\": \"%s\",\n", sha256); printf(" \"sha256\": \"%s\",\n", sha256);
printf(" \"size\": %ld,\n", size); printf(" \"size\": %ld,\n", size);
printf(" \"type\": \"%s\",\n", type); printf(" \"type\": \"%s\",\n", type);
@@ -2536,30 +2778,12 @@ void handle_upload_request(void) {
// Determine file extension from Content-Type // Get dimensions from in-memory buffer before saving file
const char* extension = ""; int width = 0, height = 0;
if (strstr(content_type, "image/jpeg")) { nip94_get_dimensions(file_data, content_length, content_type, &width, &height);
extension = ".jpg";
} else if (strstr(content_type, "image/webp")) { // Determine file extension from Content-Type using centralized mapping
extension = ".webp"; const char* extension = mime_to_extension(content_type);
} else if (strstr(content_type, "image/png")) {
extension = ".png";
} else if (strstr(content_type, "image/gif")) {
extension = ".gif";
} else if (strstr(content_type, "video/mp4")) {
extension = ".mp4";
} else if (strstr(content_type, "video/webm")) {
extension = ".webm";
} else if (strstr(content_type, "audio/mpeg")) {
extension = ".mp3";
} else if (strstr(content_type, "audio/ogg")) {
extension = ".ogg";
} else if (strstr(content_type, "text/plain")) {
extension = ".txt";
} else {
// Default to binary extension for unknown types
extension = ".bin";
}
// Save file to blobs directory with SHA-256 + extension // Save file to blobs directory with SHA-256 + extension
char filepath[MAX_PATH_LEN]; char filepath[MAX_PATH_LEN];
@@ -2677,6 +2901,14 @@ void handle_upload_request(void) {
// Get origin from config
char origin[256];
nip94_get_origin(origin, sizeof(origin));
// Build canonical blob URL
char blob_url[512];
nip94_build_blob_url(origin, sha256_hex, content_type, blob_url, sizeof(blob_url));
// Return success response with blob descriptor // Return success response with blob descriptor
printf("Status: 200 OK\r\n"); printf("Status: 200 OK\r\n");
printf("Content-Type: application/json\r\n\r\n"); printf("Content-Type: application/json\r\n\r\n");
@@ -2685,12 +2917,26 @@ void handle_upload_request(void) {
printf(" \"size\": %ld,\n", content_length); printf(" \"size\": %ld,\n", content_length);
printf(" \"type\": \"%s\",\n", content_type); printf(" \"type\": \"%s\",\n", content_type);
printf(" \"uploaded\": %ld,\n", uploaded_time); printf(" \"uploaded\": %ld,\n", uploaded_time);
printf(" \"url\": \"http://localhost:9001/%s%s\"\n", sha256_hex, extension); printf(" \"url\": \"%s\"", blob_url);
printf("}\n");
// Add NIP-94 metadata if enabled
if (nip94_is_enabled()) {
printf(",\n");
nip94_emit_field(blob_url, content_type, sha256_hex, content_length, width, height);
}
printf("\n}\n");
} }
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// MAIN
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
int main(void) { int main(void) {
fprintf(stderr, "STARTUP: FastCGI application starting up\r\n"); fprintf(stderr, "STARTUP: FastCGI application starting up\r\n");
fflush(stderr); fflush(stderr);

238
tests/nip94_test_bud08.sh Executable file
View File

@@ -0,0 +1,238 @@
#!/bin/bash
# BUD-08 NIP-94 File Metadata Tags Test Suite
# This test is created FIRST (TDD) and will FAIL until implementation is added to src/main.c.
#
# Expected procedures to be implemented in src/main.c (BUD-08 section):
# - nip94_is_enabled() -> Read server_config.nip94_enabled (default true)
# - nip94_get_origin() -> Read server_config.cdn_origin (default http://localhost:9001)
# - mime_to_extension() -> Centralize MIME to extension mapping
# - nip94_build_blob_url() -> Build canonical blob URL from origin + sha256 + extension
# - get_file_dimensions() -> Detect WxH for PNG, JPEG, WebP (parse headers only)
# - nip94_build_tags() -> Build tags: ["url",...], ["m",...], ["x",...], ["size",...], optional ["dim","WxH"]
# - nip94_attach_to_descriptor() -> Attach nip94 array to JSON response in /upload and /mirror
#
# Integration points expected:
# - PUT /upload success JSON includes "nip94" array
# - PUT /mirror success JSON includes "nip94" array
#
# Requirements:
# - curl, jq, sqlite3 must be available
# - Server should be running at http://localhost:9001 (restart-all.sh)
set -e
SERVER_URL="http://localhost:9001"
UPLOAD_ENDPOINT="${SERVER_URL}/upload"
MIRROR_ENDPOINT="${SERVER_URL}/mirror"
DB_PATH="db/ginxsom.db"
echo "=== BUD-08 NIP-94 File Metadata Tags Test Suite ==="
# Prereq checks
if ! command -v curl >/dev/null 2>&1; then
echo "ERROR: curl not found"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq not found (required for JSON parsing)"
exit 1
fi
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "ERROR: sqlite3 not found"
exit 1
fi
# Helpers
die() {
echo "FATAL: $*" 1>&2
exit 1
}
json_has_nip94() {
local json="$1"
echo "$json" | jq -e '.nip94 and ( .nip94 | type == "array" )' >/dev/null 2>&1
}
nip94_get_tag() {
local json="$1"
local key="$2"
echo "$json" | jq -r --arg k "$key" '.nip94 | map(select(.[0]==$k)) | if length>0 then .[0][1] else empty end'
}
reset_config_defaults() {
# Restore defaults used by implementation
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO server_config (key, value) VALUES ('nip94_enabled','true');" || true
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO server_config (key, value) VALUES ('cdn_origin','http://localhost:9001');" || true
}
set_config_key() {
local key="$1"
local value="$2"
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO server_config (key, value) VALUES ('$key','$value');"
}
# Create temporary working directory
WORKDIR="tests/tmp_bud08"
mkdir -p "$WORKDIR"
# Create a tiny 1x1 PNG (base64)
TINY_PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0RzjoAAAAASUVORK5CYII="
PNG_FILE="$WORKDIR/one_by_one.png"
echo "$TINY_PNG_B64" | base64 -d > "$PNG_FILE"
# Compute expected values
CONTENT_TYPE="image/png"
FILE_SIZE=$(wc -c < "$PNG_FILE" | tr -d ' ')
SHA256_HEX=$(sha256sum "$PNG_FILE" | awk '{print $1}')
echo ""
echo "Test artifact:"
echo " File: $PNG_FILE"
echo " Size: $FILE_SIZE"
echo " SHA256: $SHA256_HEX"
echo ""
# Ensure defaults
reset_config_defaults
# --- Test 1: PUT /upload returns nip94 with minimal required tags
echo "=== Test 1: PUT /upload returns nip94 minimal tags ==="
UPLOAD_JSON=$(curl -s -X PUT "$UPLOAD_ENDPOINT" \
-H "Content-Type: $CONTENT_TYPE" \
--data-binary @"$PNG_FILE")
echo "Response:"
echo "$UPLOAD_JSON"
echo ""
if json_has_nip94 "$UPLOAD_JSON"; then
URL_FIELD=$(echo "$UPLOAD_JSON" | jq -r '.url')
TYPE_FIELD=$(echo "$UPLOAD_JSON" | jq -r '.type')
SIZE_FIELD=$(echo "$UPLOAD_JSON" | jq -r '.size')
SHA256_FIELD=$(echo "$UPLOAD_JSON" | jq -r '.sha256')
TAG_URL=$(nip94_get_tag "$UPLOAD_JSON" "url")
TAG_M=$(nip94_get_tag "$UPLOAD_JSON" "m")
TAG_X=$(nip94_get_tag "$UPLOAD_JSON" "x")
TAG_SIZE=$(nip94_get_tag "$UPLOAD_JSON" "size")
PASS=1
[ -n "$TAG_URL" ] || { echo "FAIL: nip94 missing url tag"; PASS=0; }
[ -n "$TAG_M" ] || { echo "FAIL: nip94 missing m tag"; PASS=0; }
[ -n "$TAG_X" ] || { echo "FAIL: nip94 missing x tag"; PASS=0; }
[ -n "$TAG_SIZE" ] || { echo "FAIL: nip94 missing size tag"; PASS=0; }
# Validate tag values match descriptor
[ "$TAG_URL" = "$URL_FIELD" ] || { echo "FAIL: nip94 url tag != descriptor url"; PASS=0; }
[ "$TAG_M" = "$TYPE_FIELD" ] || { echo "FAIL: nip94 m tag != descriptor type"; PASS=0; }
[ "$TAG_X" = "$SHA256_FIELD" ] || { echo "FAIL: nip94 x tag != descriptor sha256"; PASS=0; }
[ "$TAG_SIZE" = "$SIZE_FIELD" ] || { echo "FAIL: nip94 size tag != descriptor size"; PASS=0; }
if [ "$PASS" = "1" ]; then
echo "✅ Test 1 PASSED: nip94 minimal tags present and correct"
else
echo "❌ Test 1 FAILED"
fi
else
echo "❌ Test 1 FAILED: Response missing nip94 array"
fi
# --- Test 2: dim present and equals 1x1 for PNG
echo ""
echo "=== Test 2: dim tag for 1x1 PNG ==="
TAG_DIM=$(nip94_get_tag "$UPLOAD_JSON" "dim" || true)
if [ -n "$TAG_DIM" ]; then
if [ "$TAG_DIM" = "1x1" ]; then
echo "✅ Test 2 PASSED: dim tag present and equals 1x1"
else
echo "❌ Test 2 FAILED: dim tag expected 1x1, got '$TAG_DIM'"
fi
else
echo "❌ Test 2 FAILED: dim tag not present"
fi
# --- Test 3: nip94 disabled via config should omit nip94 field
echo ""
echo "=== Test 3: nip94 disabled via server_config ==="
set_config_key "nip94_enabled" "false"
UPLOAD_JSON_DISABLED=$(curl -s -X PUT "$UPLOAD_ENDPOINT" \
-H "Content-Type: $CONTENT_TYPE" \
--data-binary @"$PNG_FILE")
echo "Response:"
echo "$UPLOAD_JSON_DISABLED"
echo ""
if json_has_nip94 "$UPLOAD_JSON_DISABLED"; then
echo "❌ Test 3 FAILED: nip94 present despite nip94_enabled=false"
else
echo "✅ Test 3 PASSED: nip94 omitted when nip94_enabled=false"
fi
# Restore true for next tests
set_config_key "nip94_enabled" "true"
# --- Test 4: cdn_origin config changes nip94 url (and descriptor url)
echo ""
echo "=== Test 4: cdn_origin origin override ==="
CUSTOM_ORIGIN="http://example-cdn.local"
set_config_key "cdn_origin" "$CUSTOM_ORIGIN"
UPLOAD_JSON_ORIGIN=$(curl -s -X PUT "$UPLOAD_ENDPOINT" \
-H "Content-Type: $CONTENT_TYPE" \
--data-binary @"$PNG_FILE")
echo "Response:"
echo "$UPLOAD_JSON_ORIGIN"
echo ""
if json_has_nip94 "$UPLOAD_JSON_ORIGIN"; then
URL_FIELD2=$(echo "$UPLOAD_JSON_ORIGIN" | jq -r '.url')
TAG_URL2=$(nip94_get_tag "$UPLOAD_JSON_ORIGIN" "url")
if [[ "$URL_FIELD2" == $CUSTOM_ORIGIN/* ]] && [[ "$TAG_URL2" == $CUSTOM_ORIGIN/* ]]; then
echo "✅ Test 4 PASSED: nip94 url and descriptor url use configured origin"
else
echo "❌ Test 4 FAILED: origin not applied to urls"
fi
else
echo "❌ Test 4 FAILED: Response missing nip94 array"
fi
# Restore default origin
set_config_key "cdn_origin" "http://localhost:9001"
# --- Test 5: PUT /mirror returns nip94 minimal tags (best effort, network dependent)
echo ""
echo "=== Test 5: PUT /mirror returns nip94 minimal tags (network dependent) ==="
# Use a public small PNG; if network/policy blocks, mark INFO instead of failure.
REMOTE_URL="https://upload.wikimedia.org/wikipedia/commons/3/3c/Shaki_waterfall.jpg"
MIRROR_JSON=$(curl -s -X PUT "$MIRROR_ENDPOINT" \
-H "Content-Type: application/json" \
--data "{\"url\":\"$REMOTE_URL\"}")
HTTP_OK=$(echo "$MIRROR_JSON" | jq -e '.sha256 and .type and .size' >/dev/null 2>&1; echo $?)
if [ "$HTTP_OK" = "0" ]; then
if json_has_nip94 "$MIRROR_JSON"; then
TAG_URL_M=$(nip94_get_tag "$MIRROR_JSON" "url")
TAG_M_M=$(nip94_get_tag "$MIRROR_JSON" "m")
TAG_X_M=$(nip94_get_tag "$MIRROR_JSON" "x")
TAG_SIZE_M=$(nip94_get_tag "$MIRROR_JSON" "size")
if [ -n "$TAG_URL_M" ] && [ -n "$TAG_M_M" ] && [ -n "$TAG_X_M" ] && [ -n "$TAG_SIZE_M" ]; then
echo "✅ Test 5 PASSED: nip94 minimal tags present for mirror"
else
echo "❌ Test 5 FAILED: nip94 minimal tags missing for mirror response"
fi
else
echo "❌ Test 5 FAILED: mirror response missing nip94 array"
fi
else
echo " Test 5 INFO: mirror request did not return a blob descriptor (network or policy); skipping strict check"
fi
# Cleanup and restore defaults
reset_config_defaults
rm -rf "$WORKDIR"
echo ""
echo "=== End of BUD-08 NIP-94 Test Suite ==="