add currency service, update directus setup

This commit is contained in:
2026-01-31 13:28:41 +01:00
parent 51346bdd7d
commit 32bc5aed05
2 changed files with 484 additions and 278 deletions

View File

@@ -233,7 +233,7 @@ E2E-verschlüsselter Chat mit **Zero-Knowledge Metadaten** - Server weiß nicht,
| `user` | User (M2O) | User | Auto (user_created) |
| `listing` | Listings (M2O) | Many-to-One | Required |
**Unique Constraint:** `user` + `listing`
**Duplikat-Prüfung:** Frontend-seitig vor dem Erstellen prüfen, ob bereits favorisiert.
---
@@ -243,9 +243,9 @@ E2E-verschlüsselter Chat mit **Zero-Knowledge Metadaten** - Server weiß nicht,
|------|-----|-----------|---------------|
| `id` | UUID | | Primary Key |
| `date_created` | DateTime | DateTime | Auto |
| `reporter` | User (M2O) | User | Auto |
| `listing` | Listings (M2O) | Many-to-One | Optional |
| `reported_user` | User (M2O) | Many-to-One | Optional |
| `reporter` | directus_users (M2O) | Many-to-One | Auto (user_created) |
| `listing` | listings (M2O) | Many-to-One | Optional |
| `reported_user` | directus_users (M2O) | Many-to-One | Optional |
| `reason` | String | Dropdown | `spam`, `fraud`, `inappropriate`, `illegal`, `other` |
| `details` | Text | Textarea | |
| `status` | String | Dropdown | `pending`, `reviewed`, `resolved`, `dismissed` |
@@ -328,193 +328,149 @@ App Access: ON
## 3. Access Policies
Konfiguration unter: **Settings > Access Control > [Rolle] > [Collection]**
---
### 3.1 Public (Nicht angemeldet)
**listings:**
```json
{
"read": {
"_and": [
{ "status": { "_eq": "published" } }
]
},
"fields": ["id", "title", "slug", "description", "price", "currency", "price_type", "category", "condition", "images", "location", "shipping", "date_created", "views"]
}
```
Pfad: Settings > Access Control > Public
**categories:**
```json
{
"read": {
"status": { "_eq": "published" }
},
"fields": "*"
}
```
#### listings
**locations:**
```json
{
"read": true,
"fields": "*"
}
```
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ Custom | `status` equals `published` |
| Create | ❌ | |
| Update | ❌ | |
| Delete | ❌ | |
**directus_files:**
```json
{
"read": true
}
```
**Field Permissions (Read):** `id`, `title`, `slug`, `description`, `price`, `currency`, `price_mode`, `price_type`, `category`, `condition`, `images`, `location`, `shipping`, `date_created`, `views`
#### categories
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ Custom | `status` equals `published` |
**Field Permissions:** Alle Felder
#### locations
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ All | |
#### directus_files
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ All | |
---
### 3.2 User Role Permissions
**listings:**
```json
{
"create": true,
"read": {
"_or": [
{ "status": { "_eq": "published" } },
{ "user_created": { "_eq": "$CURRENT_USER" } }
]
},
"update": {
"user_created": { "_eq": "$CURRENT_USER" }
},
"delete": {
"_and": [
{ "user_created": { "_eq": "$CURRENT_USER" } },
{ "status": { "_in": ["draft", "expired"] } }
]
}
}
```
Pfad: Settings > Access Control > User
**conversations:**
```json
{
"create": true,
"read": {
"_or": [
{ "buyer": { "_eq": "$CURRENT_USER" } },
{ "seller": { "_eq": "$CURRENT_USER" } }
]
},
"update": {
"_or": [
{ "buyer": { "_eq": "$CURRENT_USER" } },
{ "seller": { "_eq": "$CURRENT_USER" } }
]
}
}
```
#### listings
**messages:**
```json
{
"create": {
"conversation": {
"_or": [
{ "buyer": { "_eq": "$CURRENT_USER" } },
{ "seller": { "_eq": "$CURRENT_USER" } }
]
}
},
"read": {
"conversation": {
"_or": [
{ "buyer": { "_eq": "$CURRENT_USER" } },
{ "seller": { "_eq": "$CURRENT_USER" } }
]
}
}
}
```
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Create | ✅ All | |
| Read | ✅ Custom | `status` equals `published` OR `user_created` equals `$CURRENT_USER` |
| Update | ✅ Custom | `user_created` equals `$CURRENT_USER` |
| Delete | ✅ Custom | `user_created` equals `$CURRENT_USER` AND `status` in `draft, expired` |
**favorites:**
```json
{
"create": true,
"read": {
"user": { "_eq": "$CURRENT_USER" }
},
"delete": {
"user": { "_eq": "$CURRENT_USER" }
}
}
```
#### conversations
**reports:**
```json
{
"create": true
}
```
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Create | ✅ All | |
| Read | ✅ All | |
| Update | ✅ All | |
**directus_files:**
```json
{
"create": true,
"read": true,
"update": {
"uploaded_by": { "_eq": "$CURRENT_USER" }
},
"delete": {
"uploaded_by": { "_eq": "$CURRENT_USER" }
}
}
```
**Privacy-Hinweis:** Serverseitiges Filtern nicht möglich (Zero-Knowledge). Das Frontend:
1. Lädt alle Conversations für ein Listing (`listing_id` Filter)
2. Prüft client-seitig, ob eigener Hash in `participant_hash_1` oder `participant_hash_2` ist
3. Zeigt nur passende Conversations an
**directus_users (eigenes Profil):**
```json
{
"read": {
"id": { "_eq": "$CURRENT_USER" }
},
"update": {
"id": { "_eq": "$CURRENT_USER" }
},
"fields": ["id", "email", "first_name", "last_name", "avatar", "status"]
}
```
#### messages
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Create | ✅ All | |
| Read | ✅ All | |
**Hinweis:** Inhalte sind E2E-verschlüsselt, daher keine serverseitige Filterung nötig.
#### favorites
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Create | ✅ All | |
| Read | ✅ Custom | `user` equals `$CURRENT_USER` |
| Delete | ✅ Custom | `user` equals `$CURRENT_USER` |
#### reports
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Create | ✅ All | |
| Read | ❌ | |
| Update | ❌ | |
#### directus_files
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Create | ✅ All | |
| Read | ✅ All | |
| Update | ✅ Custom | `uploaded_by` equals `$CURRENT_USER` |
| Delete | ✅ Custom | `uploaded_by` equals `$CURRENT_USER` |
#### directus_users (eigenes Profil)
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ Custom | `id` equals `$CURRENT_USER` |
| Update | ✅ Custom | `id` equals `$CURRENT_USER` |
**Field Permissions:** `id`, `first_name`, `avatar`, `status`
---
### 3.3 Moderator Role Permissions
Zusätzlich zu User-Permissions:
Pfad: Settings > Access Control > Moderator
**listings:**
```json
{
"read": true,
"update": {
"fields": ["status", "admin_notes"]
}
}
```
Alle User-Permissions plus:
**reports:**
```json
{
"read": true,
"update": {
"fields": ["status", "admin_notes", "resolved_by", "resolved_at"]
}
}
```
#### listings
**directus_users (öffentliche Felder anderer User):**
```json
{
"read": {
"fields": ["id", "first_name", "avatar", "status"]
}
}
```
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ All | |
| Update | ✅ All | |
**Field Permissions (Update):** nur `status`, `admin_notes`
#### reports
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ All | |
| Update | ✅ All | |
**Field Permissions (Update):** `status`, `admin_notes`, `resolved_by`, `resolved_at`
#### directus_users (andere User sehen)
| Aktion | Erlaubt | Filter |
|--------|---------|--------|
| Read | ✅ All | |
**Field Permissions (Read):** nur `id`, `first_name`, `avatar`, `status`
---
@@ -558,9 +514,16 @@ Payload: { "slug": "{{$last.slug}}" }
### 4.2 Auto-Set Expiry Date
**Trigger:** `items.create` auf `listings`
Setzt automatisch ein Ablaufdatum 30 Tage in der Zukunft.
**Flow-Aufbau:**
1. **Trigger:** `items.create` auf `listings`
2. **Operation 1:** Run Script (Datum berechnen)
3. **Operation 2:** Update Data (Datum speichern)
```javascript
// Operation 1: Run Script
module.exports = async function(data) {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
@@ -568,54 +531,88 @@ module.exports = async function(data) {
}
```
---
### 4.3 Set Seller on Conversation Create
**Trigger:** `items.create` auf `conversations`
```javascript
module.exports = async function(data, { database }) {
const listing = await database('listings')
.where('id', data.listing)
.first();
return { seller: listing.user_created };
}
```
// Operation 2: Update Data
Collection: listings
ID: {{$trigger.key}}
Payload: { "expires_at": "{{$last.expires_at}}" }
```
**Ergebnis:** Listing am 01.01. erstellt → `expires_at` = 31.01.
---
### 4.4 Increment View Counter
### 4.3 Increment View Counter
**Trigger:** Custom Endpoint oder Hook
Erhöht den View-Counter bei jedem Aufruf einer Listing-Detailseite.
**Flow-Aufbau:**
1. **Trigger:** Webhook (vom Frontend aufgerufen)
2. **Operation 1:** Run Script (Counter erhöhen)
```
// Trigger: Webhook
Name: increment-views
Method: POST
URL: /flows/trigger/increment-views
```
```javascript
// Webhook/Endpoint für View-Tracking
// Operation 1: Run Script
module.exports = async function(data, { database }) {
if (!data.listing_id) return;
await database('listings')
.where('id', data.listing_id)
.increment('views', 1);
return { success: true };
}
```
**Frontend-Aufruf:**
```javascript
fetch('/flows/trigger/increment-views', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ listing_id: 'uuid-here' })
});
```
---
### 4.5 Auto-Expire Listings (Scheduled)
### 4.4 Auto-Expire Listings (Scheduled)
**Trigger:** Schedule (täglich um 00:00)
Markiert abgelaufene Listings automatisch als `expired`.
**Flow-Aufbau:**
1. **Trigger:** Schedule (Cron)
2. **Operation 1:** Run Script (Abgelaufene updaten)
```
// Trigger: Schedule
Type: Schedule (Cron)
Cron: 0 0 * * * (täglich um 00:00 UTC)
```
```javascript
// Operation 1: Run Script
module.exports = async function(data, { database }) {
const now = new Date().toISOString();
await database('listings')
const updated = await database('listings')
.where('expires_at', '<', now)
.where('status', 'published')
.update({ status: 'expired' });
return { expired_count: updated };
}
```
**Ergebnis:** Alle Listings mit `expires_at` in der Vergangenheit werden auf `status: expired` gesetzt.
---
## 5. Einstellungen
@@ -632,9 +629,31 @@ Project Color: #555555
### 5.2 CORS Settings
**Environment Variables (.env):**
**Option 1: docker-compose.yml**
```yaml
services:
directus:
image: directus/directus:latest
environment:
CORS_ENABLED: "true"
CORS_ORIGIN: "https://dgray.io,https://www.dgray.io,http://localhost:8080"
CORS_METHODS: "GET,POST,PATCH,DELETE"
CORS_ALLOWED_HEADERS: "Content-Type,Authorization"
CORS_CREDENTIALS: "true"
```
**Option 2: .env Datei + Docker Compose**
```yaml
# docker-compose.yml
services:
directus:
env_file: .env
```
```env
# .env
CORS_ENABLED=true
CORS_ORIGIN=https://dgray.io,https://www.dgray.io,http://localhost:8080
CORS_METHODS=GET,POST,PATCH,DELETE
@@ -644,36 +663,68 @@ CORS_CREDENTIALS=true
### 5.3 Auth Settings
Konfiguration wie CORS via **docker-compose.yml** oder **.env Datei**:
```yaml
# docker-compose.yml
environment:
ACCESS_TOKEN_TTL: "15m"
REFRESH_TOKEN_TTL: "7d"
AUTH_PASSWORD_POLICY: "/^.{36,}$/" # UUID = 36 chars
PUBLIC_REGISTRATION: "true"
```
Oder in `.env`:
```env
# Token Expiry
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=7d
# Password Policy
AUTH_PASSWORD_POLICY=/^.{8,}$/
# Disable Public Registration (optional)
# USER_REGISTER_URL_ALLOW_LIST=https://dgray.io
AUTH_PASSWORD_POLICY=/^.{36,}$/
PUBLIC_REGISTRATION=true
```
### 5.4 File Storage
```env
STORAGE_LOCATIONS=local
STORAGE_LOCAL_ROOT=./uploads
Konfiguration via **docker-compose.yml** oder **.env Datei**:
# Für S3/Cloudflare R2:
# STORAGE_LOCATIONS=s3
# STORAGE_S3_DRIVER=s3
# STORAGE_S3_KEY=xxx
# STORAGE_S3_SECRET=xxx
# STORAGE_S3_BUCKET=dgray-files
# STORAGE_S3_REGION=auto
# STORAGE_S3_ENDPOINT=https://xxx.r2.cloudflarestorage.com
**Option 1: Lokal (einfach)**
```yaml
# docker-compose.yml
environment:
STORAGE_LOCATIONS: "local"
STORAGE_LOCAL_ROOT: "./uploads"
volumes:
- ./uploads:/directus/uploads
```
**Option 2: Cloudflare R2 / S3 (empfohlen für Produktion)**
```yaml
# docker-compose.yml
environment:
STORAGE_LOCATIONS: "s3"
STORAGE_S3_DRIVER: "s3"
STORAGE_S3_KEY: "your-access-key"
STORAGE_S3_SECRET: "your-secret-key"
STORAGE_S3_BUCKET: "dgray-files"
STORAGE_S3_REGION: "auto"
STORAGE_S3_ENDPOINT: "https://xxx.r2.cloudflarestorage.com"
```
### 5.5 Rate Limiting
Konfiguration via **docker-compose.yml** oder **.env Datei**:
```yaml
# docker-compose.yml
environment:
RATE_LIMITER_ENABLED: "true"
RATE_LIMITER_STORE: "memory" # oder "redis" für Multi-Instance
RATE_LIMITER_POINTS: "100" # Max Requests
RATE_LIMITER_DURATION: "60" # Pro Minute
```
Oder in `.env`:
```env
RATE_LIMITER_ENABLED=true
RATE_LIMITER_STORE=memory
@@ -683,92 +734,42 @@ RATE_LIMITER_DURATION=60
**Hinweis:** Keine E-Mail-Konfiguration nötig - dgray.io nutzt keine E-Mails (Privacy by Design).
### 5.6 Währungsumrechnung (Kraken API)
### 5.6 Währungsumrechnung & Preismodus
Für die Anzeige von Fiat-Preisen in XMR wird die Kraken API genutzt.
Implementiert im Frontend-Service: **`js/services/currency.js`**
**API Endpoint:**
```
https://api.kraken.com/0/public/Ticker?pair=XMRUSD,XMREUR,XMRGBP,XMRCHF,XMRJPY
```
**Features:**
- Kraken API für Echtzeit-Kurse
- 5 Minuten Client-Cache
- Unterstützte Währungen: XMR, EUR, CHF, USD, GBP, JPY
- Zwei Preismodi: `fiat` (Fiat-fix) und `xmr` (XMR-fix)
**Frontend-Implementation:**
**Nutzung:**
```javascript
const KRAKEN_API = 'https://api.kraken.com/0/public/Ticker';
const PAIRS = {
USD: 'XMRUSD',
EUR: 'XMREUR',
GBP: 'XMRGBP',
CHF: 'XMRCHF',
JPY: 'XMRJPY'
};
import { getXmrRates, formatPrice } from './services/currency.js';
async function getXmrRates() {
const pairs = Object.values(PAIRS).join(',');
const response = await fetch(`${KRAKEN_API}?pair=${pairs}`);
const data = await response.json();
const rates = {};
for (const [currency, pair] of Object.entries(PAIRS)) {
const ticker = data.result[pair];
if (ticker) {
rates[currency] = parseFloat(ticker.c[0]); // Last trade price
}
}
return rates;
}
// Kurse laden
const rates = await getXmrRates();
function convertToXmr(amount, currency, rates) {
if (currency === 'XMR') return amount;
return amount / rates[currency];
}
// Beispiel: 100 EUR in XMR
// const rates = await getXmrRates();
// const xmrAmount = convertToXmr(100, 'EUR', rates);
// Preis formatieren
const listing = { price: 100, currency: 'EUR', price_mode: 'fiat' };
const display = formatPrice(listing, rates);
// → { primary: "€ 100,00", secondary: "≈ 0.6667 XMR", xmrAmount: 0.6667 }
```
**Caching:** Rates werden client-side für 5 Minuten gecached.
### 5.7 Preismodus (Fiat vs. XMR)
Listings können in zwei Modi erstellt werden:
**Preismodi:**
| Modus | `price_mode` | Verhalten |
|-------|--------------|-----------|
| **Fiat-fix** | `fiat` | Preis bleibt z.B. 100 EUR, XMR-Äquivalent ändert sich mit Kurs |
| **XMR-fix** | `xmr` | Preis bleibt z.B. 0.5 XMR, Fiat-Äquivalent ändert sich mit Kurs |
**Frontend-Anzeige:**
```javascript
function displayPrice(listing, rates) {
const { price, currency, price_mode } = listing;
if (price_mode === 'xmr' || currency === 'XMR') {
// XMR ist der Referenzpreis
const xmrPrice = currency === 'XMR' ? price : price / rates[currency];
return {
primary: `${xmrPrice.toFixed(4)} XMR`,
secondary: currency !== 'XMR' ? `${price} ${currency}` : null
};
} else {
// Fiat ist der Referenzpreis
const xmrEquivalent = price / rates[currency];
return {
primary: `${price} ${currency}`,
secondary: `${xmrEquivalent.toFixed(4)} XMR`
};
}
}
```
**Beispiele (bei 1 XMR = 150 EUR):**
**Beispiele:**
| Listing | Anzeige (bei 1 XMR = 150 EUR) |
|---------|-------------------------------|
| 100 EUR, mode=`fiat` | **100 EUR** ≈ 0.6667 XMR |
| 100 EUR, mode=`xmr` | **0.6667 XMR** ≈ 100 EUR |
| 0.5 XMR, mode=`xmr` | **0.5 XMR** ≈ 75 EUR |
| Listing | Anzeige |
|---------|---------|
| 100 EUR, mode=`fiat` | **€ 100,00** ≈ 0.6667 XMR |
| 0.5 XMR, mode=`xmr` | **0.5000 XMR** ≈ € 75,00 |
---