Compare commits
7 commits
release/v1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e23dad5c5 | |||
| 16ce3b6324 | |||
| 03fa4b803f | |||
| b6290c0f8d | |||
| 4e0f0bb072 | |||
| 638983d42a | |||
| 0823cb796c |
345 changed files with 5232 additions and 20705 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,5 +23,6 @@ yarn-error.log
|
||||||
/.nova
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/backend/coverage-report*
|
/coverage-report*
|
||||||
|
/coverage.xml
|
||||||
/.claude
|
/.claude
|
||||||
|
|
|
||||||
127
Dockerfile
Normal file
127
Dockerfile
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Production Dockerfile with FrankenPHP
|
||||||
|
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
mysql-client
|
||||||
|
|
||||||
|
# Install PHP extensions
|
||||||
|
RUN install-php-extensions \
|
||||||
|
pdo_mysql \
|
||||||
|
opcache \
|
||||||
|
zip \
|
||||||
|
gd \
|
||||||
|
intl \
|
||||||
|
bcmath \
|
||||||
|
redis \
|
||||||
|
pcntl
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set fixed production environment variables
|
||||||
|
ENV APP_ENV=production \
|
||||||
|
APP_DEBUG=false \
|
||||||
|
DB_CONNECTION=mysql \
|
||||||
|
DB_HOST=db \
|
||||||
|
DB_PORT=3306 \
|
||||||
|
SESSION_DRIVER=redis \
|
||||||
|
CACHE_STORE=redis \
|
||||||
|
QUEUE_CONNECTION=redis \
|
||||||
|
LOG_CHANNEL=stack \
|
||||||
|
LOG_LEVEL=error
|
||||||
|
|
||||||
|
# Copy application code first
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install PHP dependencies (production only)
|
||||||
|
RUN composer install --no-dev --no-interaction --optimize-autoloader
|
||||||
|
|
||||||
|
# Install ALL Node dependencies (including dev for building)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove node_modules after build to save space
|
||||||
|
RUN rm -rf node_modules
|
||||||
|
|
||||||
|
# Laravel optimizations
|
||||||
|
RUN php artisan config:cache \
|
||||||
|
&& php artisan route:cache \
|
||||||
|
&& php artisan view:cache \
|
||||||
|
&& composer dump-autoload --optimize
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
|
||||||
|
|
||||||
|
# Configure Caddy
|
||||||
|
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||||
|
{
|
||||||
|
frankenphp
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
:8000 {
|
||||||
|
root * /app/public
|
||||||
|
|
||||||
|
php_server {
|
||||||
|
index index.php
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
file_server
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/up || exit 1
|
||||||
|
|
||||||
|
# Create startup script for production
|
||||||
|
RUN cat > /start-prod.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Wait for database to be ready
|
||||||
|
echo "Waiting for database..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then
|
||||||
|
echo "Database is ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for database... ($i/30)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running migrations..."
|
||||||
|
php artisan migrate --force || echo "Migrations failed or already up-to-date"
|
||||||
|
|
||||||
|
# Start Horizon in the background
|
||||||
|
php artisan horizon &
|
||||||
|
|
||||||
|
# Start FrankenPHP
|
||||||
|
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RUN chmod +x /start-prod.sh
|
||||||
|
|
||||||
|
# Start with our script
|
||||||
|
CMD ["/start-prod.sh"]
|
||||||
127
Dockerfile.dev
Normal file
127
Dockerfile.dev
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Development Dockerfile with FrankenPHP
|
||||||
|
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||||
|
|
||||||
|
# Install system dependencies + development tools
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
mysql-client \
|
||||||
|
vim \
|
||||||
|
bash \
|
||||||
|
nano
|
||||||
|
|
||||||
|
# Install PHP extensions including xdebug for development
|
||||||
|
RUN install-php-extensions \
|
||||||
|
pdo_mysql \
|
||||||
|
opcache \
|
||||||
|
zip \
|
||||||
|
gd \
|
||||||
|
intl \
|
||||||
|
bcmath \
|
||||||
|
redis \
|
||||||
|
pcntl \
|
||||||
|
xdebug
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Configure PHP for development
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
# Configure Xdebug (disabled by default to reduce noise)
|
||||||
|
RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||||
|
&& echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||||
|
&& echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||||
|
&& echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||||
|
|
||||||
|
# Configure Caddy for development (simpler, no worker mode)
|
||||||
|
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||||
|
{
|
||||||
|
frankenphp
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
:8000 {
|
||||||
|
root * /app/public
|
||||||
|
|
||||||
|
php_server {
|
||||||
|
index index.php
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
file_server
|
||||||
|
|
||||||
|
# Less strict headers for development
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Install Node development dependencies globally
|
||||||
|
RUN npm install -g nodemon
|
||||||
|
|
||||||
|
# Create startup script for development
|
||||||
|
RUN cat > /start.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create .env file if it doesn't exist
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "Creating .env file from .env.example..."
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies if volumes are empty
|
||||||
|
if [ ! -f "vendor/autoload.php" ]; then
|
||||||
|
echo "Installing composer dependencies..."
|
||||||
|
composer install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always reinstall node_modules in container to get correct native binaries for Alpine/musl
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
rm -rf node_modules 2>/dev/null || true
|
||||||
|
rm -rf /app/.npm 2>/dev/null || true
|
||||||
|
npm install --cache /tmp/.npm
|
||||||
|
|
||||||
|
# Clear Laravel caches
|
||||||
|
php artisan config:clear || true
|
||||||
|
php artisan cache:clear || true
|
||||||
|
|
||||||
|
# Wait for database and run migrations
|
||||||
|
echo "Waiting for database..."
|
||||||
|
sleep 5
|
||||||
|
php artisan migrate --force || echo "Migration failed or not needed"
|
||||||
|
|
||||||
|
# Run seeders
|
||||||
|
echo "Running seeders..."
|
||||||
|
php artisan db:seed --force || echo "Seeding skipped or already done"
|
||||||
|
|
||||||
|
# Generate app key if not set
|
||||||
|
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
||||||
|
echo "Generating application key..."
|
||||||
|
php artisan key:generate
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Vite dev server in background
|
||||||
|
npm run dev &
|
||||||
|
|
||||||
|
# Start Horizon (queue worker) in background
|
||||||
|
php artisan horizon &
|
||||||
|
|
||||||
|
# Start FrankenPHP
|
||||||
|
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 8000 5173
|
||||||
|
|
||||||
|
# Use the startup script
|
||||||
|
CMD ["/start.sh"]
|
||||||
291
README.md
291
README.md
|
|
@ -1,219 +1,128 @@
|
||||||
# Fedi Feed Router (FFR) v1.0.0
|
# FFR (Feed to Fediverse Router)
|
||||||
|
|
||||||
<div align="center">
|
A Laravel-based application for routing RSS/Atom feeds to Fediverse platforms like Lemmy. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment.
|
||||||
<img src="backend/public/images/ffr-logo-600.png" alt="FFR Logo" width="200">
|
|
||||||
|
|
||||||
**A minimal working version — limited to two hardcoded sources, designed for self-hosters.**
|
## Features
|
||||||
*Future versions will expand configurability and support.*
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
- **Feed aggregation** - Fetch articles from multiple RSS/Atom feeds
|
||||||
|
- **Fediverse publishing** - Automatically post to Lemmy communities
|
||||||
|
- **Route configuration** - Map feeds to specific channels with keywords
|
||||||
|
- **Approval workflow** - Optional manual approval before publishing
|
||||||
|
- **Queue processing** - Background job handling with Laravel Horizon
|
||||||
|
- **Single container deployment** - Simplified hosting with FrankenPHP
|
||||||
|
|
||||||
## 🔰 Project Overview
|
## Self-hosting
|
||||||
|
|
||||||
**One-liner:** FFR routes content from RSS/Atom feeds to the fediverse based on keyword matching.
|
The production image is available at `codeberg.org/lvl0/ffr:latest`.
|
||||||
|
|
||||||
FFR is a self-hosted tool that monitors RSS/Atom feeds, filters articles based on keywords, and automatically publishes matching content to fediverse platforms like Lemmy. This v1.0.0 release provides a working foundation with two hardcoded news sources (CBC and BBC), designed specifically for self-hosters who want a simple, privacy-first solution without SaaS dependencies.
|
### docker-compose.yml
|
||||||
|
|
||||||
## ⚙️ Features
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: codeberg.org/lvl0/ffr:latest
|
||||||
|
container_name: ffr_app
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
APP_KEY: "${APP_KEY}"
|
||||||
|
APP_URL: "${APP_URL}"
|
||||||
|
DB_DATABASE: "${DB_DATABASE}"
|
||||||
|
DB_USERNAME: "${DB_USERNAME}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
volumes:
|
||||||
|
- app_storage:/app/storage
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/up"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
Current v1.0.0 features:
|
db:
|
||||||
- ✅ Fetches articles from two hardcoded RSS feeds (CBC News, BBC News)
|
image: mariadb:11
|
||||||
- ✅ Keyword-based content filtering and matching
|
container_name: ffr_db
|
||||||
- ✅ Automatic posting to Lemmy communities
|
restart: always
|
||||||
- ✅ Web dashboard for monitoring and management
|
environment:
|
||||||
- ✅ Docker-based deployment for easy self-hosting
|
MYSQL_DATABASE: "${DB_DATABASE}"
|
||||||
- ✅ Privacy-first design with no external dependencies
|
MYSQL_USER: "${DB_USERNAME}"
|
||||||
|
MYSQL_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
|
||||||
Limitations (to be addressed in future versions):
|
redis:
|
||||||
- Feed sources are currently hardcoded (not user-configurable)
|
image: redis:7-alpine
|
||||||
- Only supports Lemmy as target platform
|
container_name: ffr_redis
|
||||||
- Basic keyword matching (no regex or complex rules yet)
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
## 🚀 Installation
|
volumes:
|
||||||
|
db_data:
|
||||||
|
redis_data:
|
||||||
|
app_storage:
|
||||||
|
```
|
||||||
|
|
||||||
### Quick Start with Docker
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `APP_KEY` | Yes | Encryption key. Generate with: `echo "base64:$(openssl rand -base64 32)"` |
|
||||||
|
| `APP_URL` | Yes | Your domain (e.g., `https://ffr.example.com`) |
|
||||||
|
| `DB_DATABASE` | Yes | Database name |
|
||||||
|
| `DB_USERNAME` | Yes | Database user |
|
||||||
|
| `DB_PASSWORD` | Yes | Database password |
|
||||||
|
| `DB_ROOT_PASSWORD` | Yes | MariaDB root password |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### NixOS / Nix
|
||||||
|
|
||||||
1. **Clone the repository:**
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://codeberg.org/lvl0/ffr.git
|
git clone https://codeberg.org/lvl0/ffr.git
|
||||||
cd ffr
|
cd ffr
|
||||||
|
nix-shell
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create environment file:**
|
The shell will display available commands and optionally start the containers for you.
|
||||||
```bash
|
|
||||||
cp docker/production/.env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure your environment variables:**
|
#### Available Commands
|
||||||
```env
|
|
||||||
# Required variables only
|
|
||||||
APP_URL=http://your-domain.com:8000
|
|
||||||
DB_PASSWORD=your-secure-db-password
|
|
||||||
DB_ROOT_PASSWORD=your-secure-root-password
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start the application:**
|
| Command | Description |
|
||||||
```bash
|
|---------|-------------|
|
||||||
docker-compose -f docker/production/docker-compose.yml up -d
|
| `dev-up` | Start development environment |
|
||||||
```
|
| `dev-down` | Stop development environment |
|
||||||
|
| `dev-restart` | Restart containers |
|
||||||
|
| `dev-logs` | Follow app logs |
|
||||||
|
| `dev-logs-db` | Follow database logs |
|
||||||
|
| `dev-shell` | Enter app container |
|
||||||
|
| `dev-artisan <cmd>` | Run artisan commands |
|
||||||
|
| `prod-build [tag]` | Build and push prod image (default: latest) |
|
||||||
|
|
||||||
The application will be available at `http://localhost:8000`
|
#### Services
|
||||||
|
|
||||||
### System Requirements
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| App | http://localhost:8000 |
|
||||||
|
| Vite | http://localhost:5173 |
|
||||||
|
| MariaDB | localhost:3307 |
|
||||||
|
| Redis | localhost:6380 |
|
||||||
|
|
||||||
- Docker and Docker Compose (or Podman)
|
### Other Platforms
|
||||||
- 2GB RAM minimum
|
|
||||||
- 10GB disk space
|
|
||||||
- Linux/macOS/Windows with WSL2
|
|
||||||
|
|
||||||
## 🕹️ Usage
|
Contributions welcome for development setup instructions on other platforms.
|
||||||
|
|
||||||
### Web Interface
|
## License
|
||||||
|
|
||||||
Access the dashboard at `http://localhost:8000` to:
|
This project is open-source software licensed under the [AGPL-3.0 license](LICENSE).
|
||||||
- View fetched articles
|
|
||||||
- Monitor posting queue
|
|
||||||
- Check system logs
|
|
||||||
- Manage keywords (coming in v2.0)
|
|
||||||
|
|
||||||
### Manual Commands
|
|
||||||
|
|
||||||
Trigger article refresh manually:
|
|
||||||
```bash
|
|
||||||
docker compose exec app php artisan article:refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
View application logs:
|
|
||||||
```bash
|
|
||||||
docker compose logs -f app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scheduled Tasks
|
|
||||||
|
|
||||||
The application automatically:
|
|
||||||
- Fetches new articles every hour
|
|
||||||
- Publishes matching articles every 5 minutes
|
|
||||||
- Syncs with Lemmy communities every 10 minutes
|
|
||||||
|
|
||||||
## 📜 Logging & Debugging
|
|
||||||
|
|
||||||
**Log locations:**
|
|
||||||
- Application logs: Available in web dashboard under "Logs" section
|
|
||||||
- Docker logs: `docker compose logs -f app`
|
|
||||||
- Laravel logs: Inside container at `/var/www/html/backend/storage/logs/`
|
|
||||||
|
|
||||||
**Debug mode:**
|
|
||||||
To enable debug mode for troubleshooting, add to your `.env`:
|
|
||||||
```env
|
|
||||||
APP_DEBUG=true
|
|
||||||
```
|
|
||||||
⚠️ Remember to disable debug mode in production!
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
We welcome contributions! Here's how you can help:
|
|
||||||
|
|
||||||
1. **Report bugs:** Open an issue describing the problem
|
|
||||||
2. **Suggest features:** Create an issue with your idea
|
|
||||||
3. **Submit PRs:** Fork, create a feature branch, and submit a pull request
|
|
||||||
4. **Improve docs:** Documentation improvements are always appreciated
|
|
||||||
|
|
||||||
For development setup, see the [Development Setup](#development-setup) section below.
|
|
||||||
|
|
||||||
## 📘 License
|
|
||||||
|
|
||||||
This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3).
|
|
||||||
See [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🧭 Roadmap
|
|
||||||
|
|
||||||
### v1.0.0 (Current Release)
|
|
||||||
- ✅ Basic feed fetching from hardcoded sources
|
|
||||||
- ✅ Keyword filtering
|
|
||||||
- ✅ Lemmy posting
|
|
||||||
- ✅ Web dashboard
|
|
||||||
- ✅ Docker deployment
|
|
||||||
|
|
||||||
### v2.0.0 (Planned)
|
|
||||||
- [ ] User-configurable feed sources
|
|
||||||
- [ ] Advanced filtering rules (regex, boolean logic)
|
|
||||||
- [ ] Support for Mastodon and other ActivityPub platforms
|
|
||||||
- [ ] API for external integrations
|
|
||||||
- [ ] Multi-user support with permissions
|
|
||||||
|
|
||||||
### v3.0.0 (Future)
|
|
||||||
- [ ] Machine learning-based content categorization
|
|
||||||
- [ ] Feed discovery and recommendations
|
|
||||||
- [ ] Scheduled posting with optimal timing
|
|
||||||
- [ ] Analytics and insights dashboard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
For contributors and developers who want to work on FFR:
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Podman and podman-compose (or Docker)
|
|
||||||
- Git
|
|
||||||
- PHP 8.2+ (for local development)
|
|
||||||
- Node.js 18+ (for frontend development)
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
1. **Clone and start the development environment:**
|
|
||||||
```bash
|
|
||||||
git clone https://codeberg.org/lvl0/ffr.git
|
|
||||||
cd ffr
|
|
||||||
./docker/dev/podman/start-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Access the development environment:**
|
|
||||||
- Web interface: http://localhost:8000
|
|
||||||
- Vite dev server: http://localhost:5173
|
|
||||||
- Database: localhost:3307
|
|
||||||
- Redis: localhost:6380
|
|
||||||
|
|
||||||
### Development Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests with coverage
|
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml exec app bash -c "cd backend && XDEBUG_MODE=coverage php artisan test --coverage-html=coverage-report"
|
|
||||||
|
|
||||||
# Execute artisan commands
|
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml exec app php artisan migrate
|
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml exec app php artisan tinker
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml logs -f
|
|
||||||
|
|
||||||
# Access container shell
|
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml exec app bash
|
|
||||||
|
|
||||||
# Stop environment
|
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Features
|
|
||||||
|
|
||||||
- **Hot reload:** Vite automatically reloads frontend changes
|
|
||||||
- **Pre-seeded database:** Sample data for immediate testing
|
|
||||||
- **Laravel Horizon:** Queue monitoring dashboard
|
|
||||||
- **Xdebug:** Configured for debugging and code coverage
|
|
||||||
- **Redis:** For caching, sessions, and queues
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
For help and support:
|
For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues).
|
||||||
- 💬 Open a [Discussion](https://codeberg.org/lvl0/ffr/discussions)
|
|
||||||
- 🐛 Report [Issues](https://codeberg.org/lvl0/ffr/issues)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
Built with ❤️ for the self-hosting community
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class AuthenticatedSessionController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the login view.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming authentication request.
|
||||||
|
*/
|
||||||
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->authenticate();
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy an authenticated session.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ConfirmablePasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the confirm password view.
|
||||||
|
*/
|
||||||
|
public function show(): View
|
||||||
|
{
|
||||||
|
return view('auth.confirm-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! Auth::guard('web')->validate([
|
||||||
|
'email' => $request->user()->email,
|
||||||
|
'password' => $request->password,
|
||||||
|
])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => __('auth.password'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('auth.password_confirmed_at', time());
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EmailVerificationNotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a new email verification notification.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
|
||||||
|
return back()->with('status', 'verification-link-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class EmailVerificationPromptController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the email verification prompt.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
return $request->user()->hasVerifiedEmail()
|
||||||
|
? redirect()->intended(route('dashboard', absolute: false))
|
||||||
|
: view('auth.verify-email');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
62
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class NewPasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset view.
|
||||||
|
*/
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
return view('auth.reset-password', ['request' => $request]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming new password request.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => ['required'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Here we will attempt to reset the user's password. If it is successful we
|
||||||
|
// will update the password on an actual user model and persist it to the
|
||||||
|
// database. Otherwise we will parse the error and return the response.
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function (User $user) use ($request) {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the password was successfully reset, we will redirect the user back to
|
||||||
|
// the application's home authenticated view. If there is an error we can
|
||||||
|
// redirect them back to where they came from with their error message.
|
||||||
|
return $status == Password::PASSWORD_RESET
|
||||||
|
? redirect()->route('login')->with('status', __($status))
|
||||||
|
: back()->withInput($request->only('email'))
|
||||||
|
->withErrors(['email' => __($status)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validateWithBag('updatePassword', [
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'password-updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PasswordResetLinkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset link request view.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming password reset link request.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// We will send the password reset link to this user. Once we have attempted
|
||||||
|
// to send the link, we will examine the response then see the message we
|
||||||
|
// need to show to the user. Finally, we'll send out a proper response.
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only('email')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $status == Password::RESET_LINK_SENT
|
||||||
|
? back()->with('status', __($status))
|
||||||
|
: back()->withInput($request->only('email'))
|
||||||
|
->withErrors(['email' => __($status)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
50
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class RegisteredUserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the registration view.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming registration request.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $request->name,
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event(new Registered($user));
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
return redirect(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class VerifyEmailController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mark the authenticated user's email address as verified.
|
||||||
|
*/
|
||||||
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
|
event(new Verified($request->user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Http/Controllers/ProfileController.php
Normal file
60
app/Http/Controllers/ProfileController.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the user's profile form.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request): View
|
||||||
|
{
|
||||||
|
return view('profile.edit', [
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's profile information.
|
||||||
|
*/
|
||||||
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->fill($request->validated());
|
||||||
|
|
||||||
|
if ($request->user()->isDirty('email')) {
|
||||||
|
$request->user()->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
|
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's account.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validateWithBag('userDeletion', [
|
||||||
|
'password' => ['required', 'current_password'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return Redirect::to('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
29
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureOnboardingComplete
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OnboardingService $onboardingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Redirect to onboarding if the user hasn't completed setup.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($this->onboardingService->needsOnboarding()) {
|
||||||
|
return redirect()->route('onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Middleware/RedirectIfOnboardingComplete.php
Normal file
29
app/Http/Middleware/RedirectIfOnboardingComplete.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RedirectIfOnboardingComplete
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OnboardingService $onboardingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Redirect to dashboard if onboarding is already complete.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (!$this->onboardingService->needsOnboarding()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate the request's credentials.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
|
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||||
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($this->throttleKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the login request is not rate limited.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new Lockout($this));
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limiting throttle key for the request.
|
||||||
|
*/
|
||||||
|
public function throttleKey(): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/ProfileUpdateRequest.php
Normal file
30
app/Http/Requests/ProfileUpdateRequest.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'lowercase',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Livewire/Articles.php
Normal file
61
app/Livewire/Articles.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
class Articles extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public bool $isRefreshing = false;
|
||||||
|
|
||||||
|
public function approve(int $articleId): void
|
||||||
|
{
|
||||||
|
$article = Article::findOrFail($articleId);
|
||||||
|
$article->approve();
|
||||||
|
|
||||||
|
$this->dispatch('article-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(int $articleId): void
|
||||||
|
{
|
||||||
|
$article = Article::findOrFail($articleId);
|
||||||
|
$article->reject();
|
||||||
|
|
||||||
|
$this->dispatch('article-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
$this->isRefreshing = true;
|
||||||
|
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
// Reset after 10 seconds
|
||||||
|
$this->dispatch('refresh-complete')->self();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshComplete(): void
|
||||||
|
{
|
||||||
|
$this->isRefreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$articles = Article::with(['feed', 'articlePublication'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(15);
|
||||||
|
|
||||||
|
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
|
||||||
|
return view('livewire.articles', [
|
||||||
|
'articles' => $articles,
|
||||||
|
'approvalsEnabled' => $approvalsEnabled,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Livewire/Channels.php
Normal file
73
app/Livewire/Channels.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Channels extends Component
|
||||||
|
{
|
||||||
|
public ?int $managingChannelId = null;
|
||||||
|
|
||||||
|
public function toggle(int $channelId): void
|
||||||
|
{
|
||||||
|
$channel = PlatformChannel::findOrFail($channelId);
|
||||||
|
$channel->is_active = !$channel->is_active;
|
||||||
|
$channel->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openAccountModal(int $channelId): void
|
||||||
|
{
|
||||||
|
$this->managingChannelId = $channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeAccountModal(): void
|
||||||
|
{
|
||||||
|
$this->managingChannelId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachAccount(int $accountId): void
|
||||||
|
{
|
||||||
|
if (!$this->managingChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
||||||
|
|
||||||
|
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
||||||
|
$channel->platformAccounts()->attach($accountId, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachAccount(int $channelId, int $accountId): void
|
||||||
|
{
|
||||||
|
$channel = PlatformChannel::findOrFail($channelId);
|
||||||
|
$channel->platformAccounts()->detach($accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
||||||
|
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
||||||
|
|
||||||
|
$managingChannel = $this->managingChannelId
|
||||||
|
? PlatformChannel::with('platformAccounts')->find($this->managingChannelId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$availableAccounts = $managingChannel
|
||||||
|
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return view('livewire.channels', [
|
||||||
|
'channels' => $channels,
|
||||||
|
'managingChannel' => $managingChannel,
|
||||||
|
'availableAccounts' => $availableAccounts,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Livewire/Dashboard.php
Normal file
36
app/Livewire/Dashboard.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Dashboard extends Component
|
||||||
|
{
|
||||||
|
public string $period = 'today';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Default period
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPeriod(string $period): void
|
||||||
|
{
|
||||||
|
$this->period = $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$service = app(DashboardStatsService::class);
|
||||||
|
|
||||||
|
$articleStats = $service->getStats($this->period);
|
||||||
|
$systemStats = $service->getSystemStats();
|
||||||
|
$availablePeriods = $service->getAvailablePeriods();
|
||||||
|
|
||||||
|
return view('livewire.dashboard', [
|
||||||
|
'articleStats' => $articleStats,
|
||||||
|
'systemStats' => $systemStats,
|
||||||
|
'availablePeriods' => $availablePeriods,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Livewire/Feeds.php
Normal file
25
app/Livewire/Feeds.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Feeds extends Component
|
||||||
|
{
|
||||||
|
public function toggle(int $feedId): void
|
||||||
|
{
|
||||||
|
$feed = Feed::findOrFail($feedId);
|
||||||
|
$feed->is_active = !$feed->is_active;
|
||||||
|
$feed->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$feeds = Feed::orderBy('name')->get();
|
||||||
|
|
||||||
|
return view('livewire.feeds', [
|
||||||
|
'feeds' => $feeds,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
358
app/Livewire/Onboarding.php
Normal file
358
app/Livewire/Onboarding.php
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Language;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\PlatformInstance;
|
||||||
|
use App\Models\Route;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\Auth\LemmyAuthService;
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Onboarding extends Component
|
||||||
|
{
|
||||||
|
// Step tracking (1-6: welcome, platform, feed, channel, route, complete)
|
||||||
|
public int $step = 1;
|
||||||
|
|
||||||
|
// Platform form
|
||||||
|
public string $instanceUrl = '';
|
||||||
|
public string $username = '';
|
||||||
|
public string $password = '';
|
||||||
|
public ?array $existingAccount = null;
|
||||||
|
|
||||||
|
// Feed form
|
||||||
|
public string $feedName = '';
|
||||||
|
public string $feedProvider = 'vrt';
|
||||||
|
public ?int $feedLanguageId = null;
|
||||||
|
public string $feedDescription = '';
|
||||||
|
|
||||||
|
// Channel form
|
||||||
|
public string $channelName = '';
|
||||||
|
public ?int $platformInstanceId = null;
|
||||||
|
public ?int $channelLanguageId = null;
|
||||||
|
public string $channelDescription = '';
|
||||||
|
|
||||||
|
// Route form
|
||||||
|
public ?int $routeFeedId = null;
|
||||||
|
public ?int $routeChannelId = null;
|
||||||
|
public int $routePriority = 50;
|
||||||
|
|
||||||
|
// State
|
||||||
|
public array $formErrors = [];
|
||||||
|
public bool $isLoading = false;
|
||||||
|
|
||||||
|
protected LemmyAuthService $lemmyAuthService;
|
||||||
|
|
||||||
|
public function boot(LemmyAuthService $lemmyAuthService): void
|
||||||
|
{
|
||||||
|
$this->lemmyAuthService = $lemmyAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Check for existing platform account
|
||||||
|
$account = PlatformAccount::where('is_active', true)->first();
|
||||||
|
if ($account) {
|
||||||
|
$this->existingAccount = [
|
||||||
|
'id' => $account->id,
|
||||||
|
'username' => $account->username,
|
||||||
|
'instance_url' => $account->instance_url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill feed form if exists
|
||||||
|
$feed = Feed::where('is_active', true)->first();
|
||||||
|
if ($feed) {
|
||||||
|
$this->feedName = $feed->name;
|
||||||
|
$this->feedProvider = $feed->provider ?? 'vrt';
|
||||||
|
$this->feedLanguageId = $feed->language_id;
|
||||||
|
$this->feedDescription = $feed->description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill channel form if exists
|
||||||
|
$channel = PlatformChannel::where('is_active', true)->first();
|
||||||
|
if ($channel) {
|
||||||
|
$this->channelName = $channel->name;
|
||||||
|
$this->platformInstanceId = $channel->platform_instance_id;
|
||||||
|
$this->channelLanguageId = $channel->language_id;
|
||||||
|
$this->channelDescription = $channel->description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill route form if exists
|
||||||
|
$route = Route::where('is_active', true)->first();
|
||||||
|
if ($route) {
|
||||||
|
$this->routeFeedId = $route->feed_id;
|
||||||
|
$this->routeChannelId = $route->platform_channel_id;
|
||||||
|
$this->routePriority = $route->priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goToStep(int $step): void
|
||||||
|
{
|
||||||
|
$this->step = $step;
|
||||||
|
$this->formErrors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextStep(): void
|
||||||
|
{
|
||||||
|
$this->step++;
|
||||||
|
$this->formErrors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousStep(): void
|
||||||
|
{
|
||||||
|
if ($this->step > 1) {
|
||||||
|
$this->step--;
|
||||||
|
$this->formErrors = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function continueWithExistingAccount(): void
|
||||||
|
{
|
||||||
|
$this->nextStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAccount(): void
|
||||||
|
{
|
||||||
|
if ($this->existingAccount) {
|
||||||
|
PlatformAccount::destroy($this->existingAccount['id']);
|
||||||
|
$this->existingAccount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPlatformAccount(): void
|
||||||
|
{
|
||||||
|
$this->formErrors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
|
||||||
|
'username' => 'required|string|max:255',
|
||||||
|
'password' => 'required|string|min:6',
|
||||||
|
], [
|
||||||
|
'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fullInstanceUrl = 'https://' . $this->instanceUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create or get platform instance
|
||||||
|
$platformInstance = PlatformInstance::firstOrCreate([
|
||||||
|
'url' => $fullInstanceUrl,
|
||||||
|
'platform' => 'lemmy',
|
||||||
|
], [
|
||||||
|
'name' => ucfirst($this->instanceUrl),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Authenticate with Lemmy API
|
||||||
|
$authResponse = $this->lemmyAuthService->authenticate(
|
||||||
|
$fullInstanceUrl,
|
||||||
|
$this->username,
|
||||||
|
$this->password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create platform account
|
||||||
|
$platformAccount = PlatformAccount::create([
|
||||||
|
'platform' => 'lemmy',
|
||||||
|
'instance_url' => $fullInstanceUrl,
|
||||||
|
'username' => $this->username,
|
||||||
|
'password' => Crypt::encryptString($this->password),
|
||||||
|
'settings' => [
|
||||||
|
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
|
||||||
|
'description' => $authResponse['person_view']['person']['bio'] ?? null,
|
||||||
|
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
|
||||||
|
'platform_instance_id' => $platformInstance->id,
|
||||||
|
'api_token' => $authResponse['jwt'] ?? null,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->existingAccount = [
|
||||||
|
'id' => $platformAccount->id,
|
||||||
|
'username' => $platformAccount->username,
|
||||||
|
'instance_url' => $platformAccount->instance_url,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\App\Exceptions\PlatformAuthException $e) {
|
||||||
|
$message = $e->getMessage();
|
||||||
|
if (str_contains($message, 'Rate limited by')) {
|
||||||
|
$this->formErrors['general'] = $message;
|
||||||
|
} elseif (str_contains($message, 'Connection failed')) {
|
||||||
|
$this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
|
||||||
|
} else {
|
||||||
|
$this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.';
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Lemmy platform account creation failed', [
|
||||||
|
'instance_url' => $fullInstanceUrl,
|
||||||
|
'username' => $this->username,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'class' => get_class($e),
|
||||||
|
]);
|
||||||
|
$this->formErrors['general'] = 'An error occurred while setting up your account. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createFeed(): void
|
||||||
|
{
|
||||||
|
$this->formErrors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'feedName' => 'required|string|max:255',
|
||||||
|
'feedProvider' => 'required|in:belga,vrt',
|
||||||
|
'feedLanguageId' => 'required|exists:languages,id',
|
||||||
|
'feedDescription' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Map provider to URL
|
||||||
|
$url = $this->feedProvider === 'vrt'
|
||||||
|
? 'https://www.vrt.be/vrtnws/en/'
|
||||||
|
: 'https://www.belganewsagency.eu/';
|
||||||
|
|
||||||
|
Feed::firstOrCreate(
|
||||||
|
['url' => $url],
|
||||||
|
[
|
||||||
|
'name' => $this->feedName,
|
||||||
|
'type' => 'website',
|
||||||
|
'provider' => $this->feedProvider,
|
||||||
|
'language_id' => $this->feedLanguageId,
|
||||||
|
'description' => $this->feedDescription ?: null,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->formErrors['general'] = 'Failed to create feed. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createChannel(): void
|
||||||
|
{
|
||||||
|
$this->formErrors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'channelName' => 'required|string|max:255',
|
||||||
|
'platformInstanceId' => 'required|exists:platform_instances,id',
|
||||||
|
'channelLanguageId' => 'required|exists:languages,id',
|
||||||
|
'channelDescription' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
|
||||||
|
|
||||||
|
// Check for active platform accounts
|
||||||
|
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($activeAccounts->isEmpty()) {
|
||||||
|
$this->formErrors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.';
|
||||||
|
$this->isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::create([
|
||||||
|
'platform_instance_id' => $this->platformInstanceId,
|
||||||
|
'channel_id' => $this->channelName,
|
||||||
|
'name' => $this->channelName,
|
||||||
|
'display_name' => ucfirst($this->channelName),
|
||||||
|
'description' => $this->channelDescription ?: null,
|
||||||
|
'language_id' => $this->channelLanguageId,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach first active account
|
||||||
|
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->formErrors['general'] = 'Failed to create channel. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRoute(): void
|
||||||
|
{
|
||||||
|
$this->formErrors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'routeFeedId' => 'required|exists:feeds,id',
|
||||||
|
'routeChannelId' => 'required|exists:platform_channels,id',
|
||||||
|
'routePriority' => 'nullable|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Route::create([
|
||||||
|
'feed_id' => $this->routeFeedId,
|
||||||
|
'platform_channel_id' => $this->routeChannelId,
|
||||||
|
'priority' => $this->routePriority,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Trigger article discovery
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->formErrors['general'] = 'Failed to create route. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeOnboarding(): void
|
||||||
|
{
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'onboarding_completed'],
|
||||||
|
['value' => now()->toIso8601String()]
|
||||||
|
);
|
||||||
|
|
||||||
|
app(OnboardingService::class)->clearCache();
|
||||||
|
|
||||||
|
$this->redirect(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$languages = Language::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$feedProviders = collect(config('feed.providers', []))
|
||||||
|
->filter(fn($provider) => $provider['is_active'] ?? false)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return view('livewire.onboarding', [
|
||||||
|
'languages' => $languages,
|
||||||
|
'platformInstances' => $platformInstances,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'channels' => $channels,
|
||||||
|
'feedProviders' => $feedProviders,
|
||||||
|
])->layout('layouts.onboarding');
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Livewire/Routes.php
Normal file
200
app/Livewire/Routes.php
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Keyword;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Routes extends Component
|
||||||
|
{
|
||||||
|
public bool $showCreateModal = false;
|
||||||
|
public ?int $editingFeedId = null;
|
||||||
|
public ?int $editingChannelId = null;
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
public ?int $newFeedId = null;
|
||||||
|
public ?int $newChannelId = null;
|
||||||
|
public int $newPriority = 50;
|
||||||
|
|
||||||
|
// Edit form
|
||||||
|
public int $editPriority = 50;
|
||||||
|
|
||||||
|
// Keyword management
|
||||||
|
public string $newKeyword = '';
|
||||||
|
public bool $showKeywordInput = false;
|
||||||
|
|
||||||
|
public function openCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = true;
|
||||||
|
$this->newFeedId = null;
|
||||||
|
$this->newChannelId = null;
|
||||||
|
$this->newPriority = 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRoute(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'newFeedId' => 'required|exists:feeds,id',
|
||||||
|
'newChannelId' => 'required|exists:platform_channels,id',
|
||||||
|
'newPriority' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exists = Route::where('feed_id', $this->newFeedId)
|
||||||
|
->where('platform_channel_id', $this->newChannelId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->addError('newFeedId', 'This route already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::create([
|
||||||
|
'feed_id' => $this->newFeedId,
|
||||||
|
'platform_channel_id' => $this->newChannelId,
|
||||||
|
'priority' => $this->newPriority,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->closeCreateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openEditModal(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
$route = Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->editingFeedId = $feedId;
|
||||||
|
$this->editingChannelId = $channelId;
|
||||||
|
$this->editPriority = $route->priority;
|
||||||
|
$this->newKeyword = '';
|
||||||
|
$this->showKeywordInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeEditModal(): void
|
||||||
|
{
|
||||||
|
$this->editingFeedId = null;
|
||||||
|
$this->editingChannelId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRoute(): void
|
||||||
|
{
|
||||||
|
if (!$this->editingFeedId || !$this->editingChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editPriority' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->update(['priority' => $this->editPriority]);
|
||||||
|
|
||||||
|
$this->closeEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
$route = Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$route->is_active = !$route->is_active;
|
||||||
|
$route->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
// Delete associated keywords first
|
||||||
|
Keyword::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addKeyword(): void
|
||||||
|
{
|
||||||
|
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Keyword::create([
|
||||||
|
'feed_id' => $this->editingFeedId,
|
||||||
|
'platform_channel_id' => $this->editingChannelId,
|
||||||
|
'keyword' => trim($this->newKeyword),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->newKeyword = '';
|
||||||
|
$this->showKeywordInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleKeyword(int $keywordId): void
|
||||||
|
{
|
||||||
|
$keyword = Keyword::findOrFail($keywordId);
|
||||||
|
$keyword->is_active = !$keyword->is_active;
|
||||||
|
$keyword->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteKeyword(int $keywordId): void
|
||||||
|
{
|
||||||
|
Keyword::destroy($keywordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$routes = Route::with(['feed', 'platformChannel'])
|
||||||
|
->orderBy('priority', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Batch load keywords for all routes to avoid N+1 queries
|
||||||
|
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
||||||
|
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
||||||
|
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
||||||
|
->get()
|
||||||
|
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
||||||
|
|
||||||
|
$routes = $routes->map(function ($route) use ($allKeywords) {
|
||||||
|
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
||||||
|
$route->keywords = $allKeywords->get($key, collect());
|
||||||
|
return $route;
|
||||||
|
});
|
||||||
|
|
||||||
|
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$editingRoute = null;
|
||||||
|
$editingKeywords = collect();
|
||||||
|
|
||||||
|
if ($this->editingFeedId && $this->editingChannelId) {
|
||||||
|
$editingRoute = Route::with(['feed', 'platformChannel'])
|
||||||
|
->where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$editingKeywords = Keyword::where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('livewire.routes', [
|
||||||
|
'routes' => $routes,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'channels' => $channels,
|
||||||
|
'editingRoute' => $editingRoute,
|
||||||
|
'editingKeywords' => $editingKeywords,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Livewire/Settings.php
Normal file
55
app/Livewire/Settings.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Settings extends Component
|
||||||
|
{
|
||||||
|
public bool $articleProcessingEnabled = true;
|
||||||
|
public bool $publishingApprovalsEnabled = false;
|
||||||
|
|
||||||
|
public ?string $successMessage = null;
|
||||||
|
public ?string $errorMessage = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
||||||
|
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleArticleProcessing(): void
|
||||||
|
{
|
||||||
|
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
||||||
|
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togglePublishingApprovals(): void
|
||||||
|
{
|
||||||
|
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
||||||
|
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function showSuccess(): void
|
||||||
|
{
|
||||||
|
$this->successMessage = 'Settings updated successfully!';
|
||||||
|
$this->errorMessage = null;
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
$this->dispatch('clear-message');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearMessages(): void
|
||||||
|
{
|
||||||
|
$this->successMessage = null;
|
||||||
|
$this->errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.settings')->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,11 @@ public function canBePublished(): bool
|
||||||
return $this->isApproved();
|
return $this->isApproved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getIsPublishedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->articlePublication()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasOne<ArticlePublication, $this>
|
* @return HasOne<ArticlePublication, $this>
|
||||||
*/
|
*/
|
||||||
|
|
@ -63,6 +63,11 @@ public function login(string $username, string $password): ?string
|
||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
return $data['jwt'] ?? null;
|
return $data['jwt'] ?? null;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
// Re-throw rate limit exceptions immediately
|
||||||
|
if (str_contains($e->getMessage(), 'Rate limited')) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
|
logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
|
||||||
// If this was the first attempt and HTTPS, try HTTP next
|
// If this was the first attempt and HTTPS, try HTTP next
|
||||||
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
|
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
|
||||||
46
app/Services/OnboardingService.php
Normal file
46
app/Services/OnboardingService.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class OnboardingService
|
||||||
|
{
|
||||||
|
public function needsOnboarding(): bool
|
||||||
|
{
|
||||||
|
return Cache::remember('onboarding_needed', 300, function () {
|
||||||
|
return $this->checkOnboardingStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget('onboarding_needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkOnboardingStatus(): bool
|
||||||
|
{
|
||||||
|
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||||
|
$onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists();
|
||||||
|
|
||||||
|
// If skipped or completed, no onboarding needed
|
||||||
|
if ($onboardingCompleted || $onboardingSkipped) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all components exist
|
||||||
|
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
|
||||||
|
$hasFeed = Feed::where('is_active', true)->exists();
|
||||||
|
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
||||||
|
$hasRoute = Route::where('is_active', true)->exists();
|
||||||
|
|
||||||
|
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||||
|
|
||||||
|
return !$hasAllComponents;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue