Remove shared storage and restore user-owned uploads
This commit is contained in:
@@ -21,12 +21,3 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|||||||
|
|
||||||
# Optional: bot limits
|
# Optional: bot limits
|
||||||
# BOT_MAX_PER_USER=5
|
# BOT_MAX_PER_USER=5
|
||||||
|
|
||||||
# Optional: app-level storage fallback. Most media storage is user-owned and can be
|
|
||||||
# configured in the app, so these are only needed if you want a shared default.
|
|
||||||
# STORAGE_ENDPOINT=
|
|
||||||
# STORAGE_REGION=
|
|
||||||
# STORAGE_BUCKET=
|
|
||||||
# STORAGE_ACCESS_KEY=
|
|
||||||
# STORAGE_SECRET_KEY=
|
|
||||||
# STORAGE_PUBLIC_BASE_URL=
|
|
||||||
|
|||||||
@@ -70,14 +70,6 @@ services:
|
|||||||
# Port configuration (auto or specific port)
|
# Port configuration (auto or specific port)
|
||||||
PORT: ${PORT:-3000}
|
PORT: ${PORT:-3000}
|
||||||
|
|
||||||
# Shared S3 storage configuration
|
|
||||||
STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-}
|
|
||||||
STORAGE_REGION: ${STORAGE_REGION:-us-east-1}
|
|
||||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-}
|
|
||||||
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-}
|
|
||||||
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY:-}
|
|
||||||
STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-}
|
|
||||||
|
|
||||||
# Node environment
|
# Node environment
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -43,16 +43,3 @@ PORT=auto
|
|||||||
|
|
||||||
# Maximum AI bots per user (default: 5)
|
# Maximum AI bots per user (default: 5)
|
||||||
# BOT_MAX_PER_USER=5
|
# BOT_MAX_PER_USER=5
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# OPTIONAL: Shared S3 Storage
|
|
||||||
# ===========================================
|
|
||||||
# These are only needed if you want app-level shared storage defaults.
|
|
||||||
# Most media storage is configured per user inside the app.
|
|
||||||
|
|
||||||
# STORAGE_ENDPOINT=
|
|
||||||
# STORAGE_REGION=us-east-1
|
|
||||||
# STORAGE_BUCKET=
|
|
||||||
# STORAGE_ACCESS_KEY=
|
|
||||||
# STORAGE_SECRET_KEY=
|
|
||||||
# STORAGE_PUBLIC_BASE_URL=
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ RUN apk add --no-cache libc6-compat openssl netcat-openbsd wget
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/GnosysLabs/Synapsis"
|
||||||
|
LABEL org.opencontainers.image.description="Synapsis self-hosted social node"
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|||||||
@@ -42,15 +42,6 @@ Optional (advanced):
|
|||||||
- `NEXT_PUBLIC_NODE_DOMAIN` to override the node domain (defaults to `DOMAIN`)
|
- `NEXT_PUBLIC_NODE_DOMAIN` to override the node domain (defaults to `DOMAIN`)
|
||||||
- `NEXT_PUBLIC_APP_URL` to override the public app URL used by background jobs (auto-derived from the node domain)
|
- `NEXT_PUBLIC_APP_URL` to override the public app URL used by background jobs (auto-derived from the node domain)
|
||||||
- `ALLOW_LOCALHOST=1` to allow `localhost` in production containers for local testing
|
- `ALLOW_LOCALHOST=1` to allow `localhost` in production containers for local testing
|
||||||
- Shared S3 storage env vars are available if you want app-level fallback storage
|
|
||||||
|
|
||||||
Optional shared storage env vars:
|
|
||||||
- `STORAGE_ENDPOINT`
|
|
||||||
- `STORAGE_REGION`
|
|
||||||
- `STORAGE_BUCKET`
|
|
||||||
- `STORAGE_ACCESS_KEY`
|
|
||||||
- `STORAGE_SECRET_KEY`
|
|
||||||
- `STORAGE_PUBLIC_BASE_URL`
|
|
||||||
|
|
||||||
**Port Configuration:**
|
**Port Configuration:**
|
||||||
- `PORT=auto` (default) — Automatically finds an available port between 3000-3020
|
- `PORT=auto` (default) — Automatically finds an available port between 3000-3020
|
||||||
|
|||||||
@@ -63,14 +63,6 @@ services:
|
|||||||
# Admin emails
|
# Admin emails
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS}
|
ADMIN_EMAILS: ${ADMIN_EMAILS}
|
||||||
|
|
||||||
# S3 Storage configuration
|
|
||||||
STORAGE_ENDPOINT: ${STORAGE_ENDPOINT}
|
|
||||||
STORAGE_REGION: ${STORAGE_REGION:-us-east-1}
|
|
||||||
STORAGE_BUCKET: ${STORAGE_BUCKET}
|
|
||||||
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY}
|
|
||||||
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
|
||||||
STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL}
|
|
||||||
|
|
||||||
# Optional settings
|
# Optional settings
|
||||||
BOT_MAX_PER_USER: ${BOT_MAX_PER_USER:-5}
|
BOT_MAX_PER_USER: ${BOT_MAX_PER_USER:-5}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,3 @@ echo "Next steps:"
|
|||||||
echo " 1. Review ${INSTALL_DIR}/.env"
|
echo " 1. Review ${INSTALL_DIR}/.env"
|
||||||
echo " 2. Start Synapsis:"
|
echo " 2. Start Synapsis:"
|
||||||
echo " cd ${INSTALL_DIR} && docker compose up -d"
|
echo " cd ${INSTALL_DIR} && docker compose up -d"
|
||||||
echo ""
|
|
||||||
echo "One-line usage examples:"
|
|
||||||
echo " curl -fsSL ${PUBLIC_INSTALL_URL} | bash"
|
|
||||||
echo " curl -fsSL ${PUBLIC_INSTALL_URL} | DOMAIN=synapsis.example.com ADMIN_EMAILS=you@example.com bash"
|
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export async function POST(req: NextRequest) {
|
|||||||
if (error instanceof Error && error.message === 'Authentication required') {
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Invalid storage password') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Incorrect password. Please try again.'
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
if (error instanceof Error && error.message.includes('Storage')) {
|
if (error instanceof Error && error.message.includes('Storage')) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const formData = await req.formData();
|
|
||||||
const file = formData.get('file') as File;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
|
||||||
|
|
||||||
// S3 Configuration
|
|
||||||
const s3 = new S3Client({
|
|
||||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
|
||||||
endpoint: process.env.STORAGE_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
|
||||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
|
||||||
},
|
|
||||||
forcePathStyle: true, // Needed for many S3-compatible providers
|
|
||||||
});
|
|
||||||
|
|
||||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
|
||||||
|
|
||||||
await s3.send(new PutObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: filename,
|
|
||||||
Body: buffer,
|
|
||||||
ContentType: file.type,
|
|
||||||
ACL: 'public-read', // Depends on bucket policy, but often needed
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Construct Public URL
|
|
||||||
let url = '';
|
|
||||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
|
||||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
|
||||||
} else if (process.env.STORAGE_ENDPOINT) {
|
|
||||||
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
|
|
||||||
} else {
|
|
||||||
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ url });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+32
-114
@@ -12,6 +12,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
|
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
|
||||||
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ContentSource {
|
interface ContentSource {
|
||||||
@@ -46,8 +47,7 @@ export default function EditBotPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
const [storagePassword, setStoragePassword] = useState('');
|
||||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -364,119 +364,37 @@ export default function EditBotPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<UserStorageImageUpload
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
label="Avatar"
|
||||||
Avatar
|
value={formData.avatarUrl}
|
||||||
</label>
|
onChange={(avatarUrl) => {
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
setError('');
|
||||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
setFormData(prev => ({ ...prev, avatarUrl }));
|
||||||
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
|
}}
|
||||||
<input
|
password={storagePassword}
|
||||||
type="file"
|
onPasswordChange={setStoragePassword}
|
||||||
accept="image/*"
|
previewWidth={48}
|
||||||
onChange={async (e) => {
|
previewHeight={48}
|
||||||
const file = e.target.files?.[0];
|
previewBorderRadius="50%"
|
||||||
if (!file) return;
|
helperText="Square image recommended (optional)"
|
||||||
setUploadingAvatar(true);
|
onError={setError}
|
||||||
try {
|
/>
|
||||||
const uploadData = new FormData();
|
|
||||||
uploadData.append('file', file);
|
|
||||||
const res = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: uploadData,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Avatar upload failed:', err);
|
|
||||||
setError('Avatar upload failed');
|
|
||||||
} finally {
|
|
||||||
setUploadingAvatar(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={uploadingAvatar}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{formData.avatarUrl && (
|
|
||||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
||||||
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{formData.avatarUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
style={{ color: 'var(--error)' }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
||||||
Square image recommended (optional)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<UserStorageImageUpload
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
label="Banner"
|
||||||
Banner
|
value={formData.headerUrl}
|
||||||
</label>
|
onChange={(headerUrl) => {
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
setError('');
|
||||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
setFormData(prev => ({ ...prev, headerUrl }));
|
||||||
{uploadingBanner ? 'Uploading...' : 'Choose File'}
|
}}
|
||||||
<input
|
password={storagePassword}
|
||||||
type="file"
|
onPasswordChange={setStoragePassword}
|
||||||
accept="image/*"
|
previewWidth={120}
|
||||||
onChange={async (e) => {
|
previewHeight={40}
|
||||||
const file = e.target.files?.[0];
|
previewBorderRadius="4px"
|
||||||
if (!file) return;
|
helperText="Wide image recommended, e.g. 1500x500 (optional)"
|
||||||
setUploadingBanner(true);
|
onError={setError}
|
||||||
try {
|
/>
|
||||||
const uploadData = new FormData();
|
|
||||||
uploadData.append('file', file);
|
|
||||||
const res = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: uploadData,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
setFormData(prev => ({ ...prev, headerUrl: data.url }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Banner upload failed:', err);
|
|
||||||
setError('Banner upload failed');
|
|
||||||
} finally {
|
|
||||||
setUploadingBanner(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={uploadingBanner}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{formData.headerUrl && (
|
|
||||||
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
||||||
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{formData.headerUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
style={{ color: 'var(--error)' }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
||||||
Wide image recommended, e.g. 1500x500 (optional)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+33
-114
@@ -12,6 +12,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
|
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
|
||||||
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ContentSource {
|
interface ContentSource {
|
||||||
@@ -39,8 +40,7 @@ export default function NewBotPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
const [storagePassword, setStoragePassword] = useState('');
|
||||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -183,6 +183,7 @@ export default function NewBotPage() {
|
|||||||
llmProvider: formData.llmProvider,
|
llmProvider: formData.llmProvider,
|
||||||
llmModel: formData.llmModel,
|
llmModel: formData.llmModel,
|
||||||
llmApiKey: formData.llmApiKey,
|
llmApiKey: formData.llmApiKey,
|
||||||
|
ownerPassword: storagePassword || undefined,
|
||||||
autonomousMode: formData.autonomousMode,
|
autonomousMode: formData.autonomousMode,
|
||||||
schedule: formData.autonomousMode ? {
|
schedule: formData.autonomousMode ? {
|
||||||
type: 'interval',
|
type: 'interval',
|
||||||
@@ -313,119 +314,37 @@ export default function NewBotPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<UserStorageImageUpload
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
label="Avatar"
|
||||||
Avatar
|
value={formData.avatarUrl}
|
||||||
</label>
|
onChange={(avatarUrl) => {
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
setError('');
|
||||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
setFormData(prev => ({ ...prev, avatarUrl }));
|
||||||
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
|
}}
|
||||||
<input
|
password={storagePassword}
|
||||||
type="file"
|
onPasswordChange={setStoragePassword}
|
||||||
accept="image/*"
|
previewWidth={48}
|
||||||
onChange={async (e) => {
|
previewHeight={48}
|
||||||
const file = e.target.files?.[0];
|
previewBorderRadius="50%"
|
||||||
if (!file) return;
|
helperText="Square image recommended (optional)"
|
||||||
setUploadingAvatar(true);
|
onError={setError}
|
||||||
try {
|
/>
|
||||||
const uploadData = new FormData();
|
|
||||||
uploadData.append('file', file);
|
|
||||||
const res = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: uploadData,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Avatar upload failed:', err);
|
|
||||||
setError('Avatar upload failed');
|
|
||||||
} finally {
|
|
||||||
setUploadingAvatar(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={uploadingAvatar}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{formData.avatarUrl && (
|
|
||||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
||||||
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{formData.avatarUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
style={{ color: 'var(--error)' }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
||||||
Square image recommended (optional)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<UserStorageImageUpload
|
||||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
label="Banner"
|
||||||
Banner
|
value={formData.headerUrl}
|
||||||
</label>
|
onChange={(headerUrl) => {
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
setError('');
|
||||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
setFormData(prev => ({ ...prev, headerUrl }));
|
||||||
{uploadingBanner ? 'Uploading...' : 'Choose File'}
|
}}
|
||||||
<input
|
password={storagePassword}
|
||||||
type="file"
|
onPasswordChange={setStoragePassword}
|
||||||
accept="image/*"
|
previewWidth={120}
|
||||||
onChange={async (e) => {
|
previewHeight={40}
|
||||||
const file = e.target.files?.[0];
|
previewBorderRadius="4px"
|
||||||
if (!file) return;
|
helperText="Wide image recommended, e.g. 1500x500 (optional)"
|
||||||
setUploadingBanner(true);
|
onError={setError}
|
||||||
try {
|
/>
|
||||||
const uploadData = new FormData();
|
|
||||||
uploadData.append('file', file);
|
|
||||||
const res = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: uploadData,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
setFormData(prev => ({ ...prev, headerUrl: data.url }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Banner upload failed:', err);
|
|
||||||
setError('Banner upload failed');
|
|
||||||
} finally {
|
|
||||||
setUploadingBanner(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={uploadingBanner}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{formData.headerUrl && (
|
|
||||||
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
||||||
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{formData.headerUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
style={{ color: 'var(--error)' }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
||||||
Wide image recommended, e.g. 1500x500 (optional)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+34
-119
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { User, Post } from '@/lib/types';
|
import { User, Post } from '@/lib/types';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
|
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
|
||||||
|
import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
|
||||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
@@ -114,68 +115,7 @@ export default function ProfilePage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isBlocked, setIsBlocked] = useState(false);
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [storagePassword, setStoragePassword] = useState('');
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const headerInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: 'avatarUrl' | 'headerUrl') => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Reset inputs so change event fires again for same file
|
|
||||||
e.target.value = '';
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
// 1. Upload the file
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const uploadRes = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
const uploadData = await uploadRes.json();
|
|
||||||
|
|
||||||
if (!uploadData.url) throw new Error('Upload failed');
|
|
||||||
const imageUrl = uploadData.url;
|
|
||||||
|
|
||||||
// 2. Update local form state immediately for UI feedback
|
|
||||||
setProfileForm(prev => ({ ...prev, [field]: imageUrl }));
|
|
||||||
|
|
||||||
// 3. Auto-save the profile change
|
|
||||||
if (!isIdentityUnlocked) {
|
|
||||||
throw new Error('Session expired. Please log in again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create partial update payload
|
|
||||||
const updatePayload: any = { [field]: imageUrl };
|
|
||||||
|
|
||||||
// Sign the action
|
|
||||||
const signedPayload = await signUserAction('update_profile', updatePayload);
|
|
||||||
|
|
||||||
const saveRes = await fetch('/api/auth/me', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(signedPayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveData = await saveRes.json();
|
|
||||||
|
|
||||||
if (!saveRes.ok) {
|
|
||||||
throw new Error(saveData.error || 'Failed to update profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user state with the returned updated user
|
|
||||||
setUser(saveData.user);
|
|
||||||
// We do NOT exit edit mode automatically, allowing them to edit other fields
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSaveError(err instanceof Error ? err.message : 'Upload failed');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -558,34 +498,8 @@ export default function ProfilePage() {
|
|||||||
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
||||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
cursor: isEditing ? 'pointer' : 'default',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (isEditing) {
|
|
||||||
headerInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditing && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.3)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'white'
|
|
||||||
}}>
|
|
||||||
<Camera size={32} />
|
|
||||||
<input
|
|
||||||
ref={headerInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => handleUpload(e, 'headerUrl')}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatar & Actions */}
|
{/* Avatar & Actions */}
|
||||||
@@ -604,12 +518,6 @@ export default function ProfilePage() {
|
|||||||
border: '4px solid var(--background)',
|
border: '4px solid var(--background)',
|
||||||
marginTop: '-48px',
|
marginTop: '-48px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
cursor: isEditing ? 'pointer' : 'default',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (isEditing) {
|
|
||||||
avatarInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
|
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
|
||||||
@@ -617,28 +525,6 @@ export default function ProfilePage() {
|
|||||||
) : (
|
) : (
|
||||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditing && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.3)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'white'
|
|
||||||
}}>
|
|
||||||
<Camera size={24} />
|
|
||||||
<input
|
|
||||||
ref={avatarInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => handleUpload(e, 'avatarUrl')}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
@@ -852,7 +738,36 @@ export default function ProfilePage() {
|
|||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* File inputs removed, now click-to-upload on visual elements */}
|
<UserStorageImageUpload
|
||||||
|
label="Avatar"
|
||||||
|
value={profileForm.avatarUrl}
|
||||||
|
onChange={(avatarUrl) => {
|
||||||
|
setSaveError(null);
|
||||||
|
setProfileForm({ ...profileForm, avatarUrl });
|
||||||
|
}}
|
||||||
|
password={storagePassword}
|
||||||
|
onPasswordChange={setStoragePassword}
|
||||||
|
previewWidth={48}
|
||||||
|
previewHeight={48}
|
||||||
|
previewBorderRadius="50%"
|
||||||
|
helperText="Square image recommended (optional)"
|
||||||
|
onError={(message) => setSaveError(message || null)}
|
||||||
|
/>
|
||||||
|
<UserStorageImageUpload
|
||||||
|
label="Header"
|
||||||
|
value={profileForm.headerUrl}
|
||||||
|
onChange={(headerUrl) => {
|
||||||
|
setSaveError(null);
|
||||||
|
setProfileForm({ ...profileForm, headerUrl });
|
||||||
|
}}
|
||||||
|
password={storagePassword}
|
||||||
|
onPasswordChange={setStoragePassword}
|
||||||
|
previewWidth={120}
|
||||||
|
previewHeight={40}
|
||||||
|
previewBorderRadius="4px"
|
||||||
|
helperText="Wide image recommended, e.g. 1500x500 (optional)"
|
||||||
|
onError={(message) => setSaveError(message || null)}
|
||||||
|
/>
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface UserStorageImageUploadProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
password: string;
|
||||||
|
onPasswordChange: (password: string) => void;
|
||||||
|
helperText?: string;
|
||||||
|
previewWidth?: number;
|
||||||
|
previewHeight?: number;
|
||||||
|
previewBorderRadius?: string;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserStorageImageUpload({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
password,
|
||||||
|
onPasswordChange,
|
||||||
|
helperText,
|
||||||
|
previewWidth = 48,
|
||||||
|
previewHeight = 48,
|
||||||
|
previewBorderRadius = '8px',
|
||||||
|
onError,
|
||||||
|
}: UserStorageImageUploadProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [passwordInput, setPasswordInput] = useState(password);
|
||||||
|
const [promptError, setPromptError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPasswordPrompt) {
|
||||||
|
setPromptError('');
|
||||||
|
}
|
||||||
|
}, [showPasswordPrompt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPasswordPrompt && !passwordInput && password) {
|
||||||
|
setPasswordInput(password);
|
||||||
|
}
|
||||||
|
}, [showPasswordPrompt, password, passwordInput]);
|
||||||
|
|
||||||
|
const reportError = (message: string) => {
|
||||||
|
setPromptError(message);
|
||||||
|
onError?.(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFileInput = () => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file: File, uploadPassword: string) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
setPromptError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('password', uploadPassword);
|
||||||
|
|
||||||
|
const res = await fetch('/api/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
const message = data.error || 'Upload failed';
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
onPasswordChange('');
|
||||||
|
setPasswordInput('');
|
||||||
|
setPendingFile(file);
|
||||||
|
setShowPasswordPrompt(true);
|
||||||
|
setPromptError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPasswordChange(uploadPassword);
|
||||||
|
onChange(data.media?.url || data.url);
|
||||||
|
onError?.('');
|
||||||
|
setPendingFile(null);
|
||||||
|
setShowPasswordPrompt(false);
|
||||||
|
setPasswordInput(uploadPassword);
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error instanceof Error ? error.message : 'Upload failed');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
resetFileInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setPendingFile(file);
|
||||||
|
|
||||||
|
if (password.trim()) {
|
||||||
|
await uploadFile(file, password.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordInput('');
|
||||||
|
setShowPasswordPrompt(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!pendingFile) {
|
||||||
|
setShowPasswordPrompt(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordInput.trim()) {
|
||||||
|
setPromptError('Please enter your password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(pendingFile, passwordInput.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setShowPasswordPrompt(false);
|
||||||
|
setPendingFile(null);
|
||||||
|
setPasswordInput(password);
|
||||||
|
setPromptError('');
|
||||||
|
resetFileInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<label className="btn btn-ghost btn-sm" style={{ cursor: isUploading ? 'default' : 'pointer' }}>
|
||||||
|
{isUploading ? 'Uploading...' : 'Choose File'}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${previewWidth}px`,
|
||||||
|
height: `${previewHeight}px`,
|
||||||
|
borderRadius: previewBorderRadius,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--background-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt={`${label} preview`}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{ color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{helperText && (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPasswordPrompt && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 99999,
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ width: '100%', maxWidth: '420px', padding: '20px' }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
|
||||||
|
Unlock storage upload
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px', lineHeight: 1.5 }}>
|
||||||
|
Enter your account password to upload this image to your own storage.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handlePasswordSubmit}>
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="storage-upload-password"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="storage-upload-password"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={passwordInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPasswordInput(event.target.value);
|
||||||
|
setPromptError('');
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{promptError && (
|
||||||
|
<div style={{ color: 'var(--error)', fontSize: '13px', marginBottom: '12px' }}>
|
||||||
|
{promptError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={handleCancel} disabled={isUploading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isUploading}>
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
export async function generateAndUploadAvatar(handle: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
// 1. Fetch the avatar from DiceBear (PNG format for better compatibility)
|
|
||||||
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/png?seed=${handle}`;
|
|
||||||
const response = await fetch(dicebearUrl);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
// 2. Upload to S3
|
|
||||||
const s3 = new S3Client({
|
|
||||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
|
||||||
endpoint: process.env.STORAGE_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
|
||||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
|
||||||
// Sanitize handle for filename just in case
|
|
||||||
const safeHandle = handle.replace(/[^a-zA-Z0-9]/g, '');
|
|
||||||
const filename = `${uuidv4()}-${safeHandle}-avatar.png`;
|
|
||||||
|
|
||||||
await s3.send(new PutObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: filename,
|
|
||||||
Body: buffer,
|
|
||||||
ContentType: 'image/png',
|
|
||||||
ACL: 'public-read',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. Construct Public URL
|
|
||||||
let url = '';
|
|
||||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
|
||||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
|
||||||
} else if (process.env.STORAGE_ENDPOINT) {
|
|
||||||
// Basic fallback construction if base url is not set but endpoint is
|
|
||||||
// This assumes path style access is okay if no custom domain
|
|
||||||
const endpoint = process.env.STORAGE_ENDPOINT.replace(/\/+$/, '');
|
|
||||||
url = `${endpoint}/${bucket}/${filename}`;
|
|
||||||
} else {
|
|
||||||
console.warn('Storage public URL not configured properly');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating/uploading avatar:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+13
-9
@@ -30,15 +30,19 @@ export function decryptS3Credentials(
|
|||||||
encryptedSecretKey: string,
|
encryptedSecretKey: string,
|
||||||
password: string
|
password: string
|
||||||
): { accessKeyId: string; secretAccessKey: string } {
|
): { accessKeyId: string; secretAccessKey: string } {
|
||||||
const accessKeyId = decryptPrivateKey(
|
try {
|
||||||
deserializeEncryptedKey(encryptedAccessKey),
|
const accessKeyId = decryptPrivateKey(
|
||||||
password
|
deserializeEncryptedKey(encryptedAccessKey),
|
||||||
);
|
password
|
||||||
const secretAccessKey = decryptPrivateKey(
|
);
|
||||||
deserializeEncryptedKey(encryptedSecretKey),
|
const secretAccessKey = decryptPrivateKey(
|
||||||
password
|
deserializeEncryptedKey(encryptedSecretKey),
|
||||||
);
|
password
|
||||||
return { accessKeyId, secretAccessKey };
|
);
|
||||||
|
return { accessKeyId, secretAccessKey };
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid storage password');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user