Compare commits

...

18 commits

Author SHA1 Message Date
35e4260c87 76 - Lock feed language to channel language in onboarding wizard 2026-03-07 10:43:48 +01:00
2a0653981c 40 - Run seeders on build
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
2026-03-07 09:22:00 +01:00
7b3a59da10 40 - Fix javascript/livewire issue
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m16s
2026-03-07 00:45:51 +01:00
86b1fe7263 40 - Add base image for faster CI build
All checks were successful
Build and Push Docker Image / build (push) Successful in 5m10s
2026-03-07 00:27:16 +01:00
005abb1877 40 - Fix npm install in Dockerfile and remove .github workflows
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m44s
2026-03-07 00:03:11 +01:00
8ea0365b8b 40 - Add Forgejo CI workflow for Docker builds
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m58s
2026-03-06 23:48:46 +01:00
71e7611e65 68 - Sync channel posts on setup for duplicate detection 2026-02-26 01:38:38 +01:00
c75a1c8cf0 68 - Fix duplicate posting and publishing pipeline 2026-02-26 01:19:38 +01:00
8bc6e99f96 68 - Fix duplicate posting + test suite fixes 2026-02-25 23:22:05 +01:00
d3c44a4952 63 - Swap feed and channel steps in onboarding wizard 2026-02-25 21:13:42 +01:00
b574785d1e 75 - Fix error handling for invalid instance URLs 2026-02-25 20:36:34 +01:00
3e23dad5c5 Minor bug fixes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
2026-02-25 20:22:02 +01:00
16ce3b6324 74 - Minor fixes
- Add logout button to onboarding menu
- remove Forgot Password
2026-01-23 01:00:37 +01:00
03fa4b803f 74 - Fix auth + onboarding layout 2026-01-23 00:56:01 +01:00
b6290c0f8d 73 - Fix prod environment 2026-01-23 00:32:42 +01:00
4e0f0bb072 73 - Fix dev environment 2026-01-23 00:08:32 +01:00
638983d42a 73 - Port react frontend to blade+livewire 2026-01-22 23:38:00 +01:00
0823cb796c 73 - Move backend to root 2026-01-22 21:55:57 +01:00
352 changed files with 5983 additions and 21100 deletions

View file

@ -23,14 +23,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=db
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=ffr_dev
# DB_USERNAME=root DB_USERNAME=ffr
# DB_PASSWORD= DB_PASSWORD=ffr
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@ -38,15 +38,15 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis
CACHE_STORE=database CACHE_STORE=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

View file

@ -0,0 +1,41 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:${TAG},forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

View file

@ -1,45 +0,0 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'

View file

@ -1,50 +0,0 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm ci
- name: Build Assets
run: npm run build
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Tests
run: ./vendor/bin/phpunit

4
.gitignore vendored
View file

@ -4,6 +4,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/public/vendor
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
@ -23,5 +24,6 @@ yarn-error.log
/.nova /.nova
/.vscode /.vscode
/.zed /.zed
/backend/coverage-report* /coverage-report*
/coverage.xml
/.claude /.claude

111
Dockerfile Normal file
View file

@ -0,0 +1,111 @@
# Production Dockerfile - uses pre-built base image
FROM forge.lvl0.xyz/lvl0/fedi-feed-router-base:latest
# 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=${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 install
# Build frontend assets
RUN npm run build
# Remove node_modules after build to save space
RUN rm -rf node_modules
# Publish Livewire assets
RUN php artisan livewire:publish --assets
# Laravel optimizations (skip config and route cache - they need runtime env vars)
RUN 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
encode gzip
php_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
# Cache config with runtime env vars
echo "Caching configuration..."
php artisan config:cache
# Run migrations
echo "Running migrations..."
php artisan migrate --force || echo "Migrations failed or already up-to-date"
# Run seeders (idempotent - uses updateOrInsert)
echo "Running seeders..."
php artisan db:seed --force || echo "Seeders failed or already run"
# 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
View 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"]

298
README.md
View file

@ -1,219 +1,127 @@
# 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 `forge.lvl0.xyz/lvl0/fedi-feed-router: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: forge.lvl0.xyz/lvl0/fedi-feed-router: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:
### Quick Start with Docker redis_data:
app_storage:
1. **Clone the repository:**
```bash
git clone https://codeberg.org/lvl0/ffr.git
cd ffr
```
2. **Create environment file:**
```bash
cp docker/production/.env.example .env
```
3. **Configure your environment variables:**
```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:**
```bash
docker-compose -f docker/production/docker-compose.yml up -d
```
The application will be available at `http://localhost:8000`
### System Requirements
- Docker and Docker Compose (or Podman)
- 2GB RAM minimum
- 10GB disk space
- Linux/macOS/Windows with WSL2
## 🕹️ Usage
### Web Interface
Access the dashboard at `http://localhost:8000` to:
- 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: ### Environment Variables
```bash
docker compose logs -f app
```
### Scheduled Tasks | 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 |
The application automatically: ## Development
- Fetches new articles every hour
- Publishes matching articles every 5 minutes
- Syncs with Lemmy communities every 10 minutes
## 📜 Logging & Debugging ### NixOS / Nix
**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 ```bash
# Run tests with coverage git clone https://forge.lvl0.xyz/lvl0/fedi-feed-router.git
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" cd ffr
nix-shell
# 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 The shell will display available commands and optionally start the containers for you.
- **Hot reload:** Vite automatically reloads frontend changes #### Available Commands
- **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
--- | Command | Description |
|---------|-------------|
| `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 |
#### Services
| Service | URL |
|---------|-----|
| App | http://localhost:8000 |
| Vite | http://localhost:5173 |
| MariaDB | localhost:3307 |
| Redis | localhost:6380 |
### Other Platforms
Contributions welcome for development setup instructions on other platforms.
## License
This project is open-source software licensed under the [AGPL-3.0 license](LICENSE).
## Support ## Support
For help and support: For issues and questions, please use [Issues](https://forge.lvl0.xyz/lvl0/fedi-feed-router/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>

View file

@ -34,15 +34,15 @@ public function update(Request $request): JsonResponse
try { try {
$validated = $request->validate([ $validated = $request->validate([
'article_processing_enabled' => 'boolean', 'article_processing_enabled' => 'boolean',
'enable_publishing_approvals' => 'boolean', 'publishing_approvals_enabled' => 'boolean',
]); ]);
if (isset($validated['article_processing_enabled'])) { if (isset($validated['article_processing_enabled'])) {
Setting::setArticleProcessingEnabled($validated['article_processing_enabled']); Setting::setArticleProcessingEnabled($validated['article_processing_enabled']);
} }
if (isset($validated['enable_publishing_approvals'])) { if (isset($validated['publishing_approvals_enabled'])) {
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']); Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
} }
$updatedSettings = [ $updatedSettings = [
@ -60,4 +60,4 @@ public function update(Request $request): JsonResponse
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
} }
} }
} }

View 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('/');
}
}

View 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));
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View 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)]);
}
}

View 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');
}
}

View 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)]);
}
}

View 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));
}
}

View 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');
}
}

View 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('/');
}
}

View 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);
}
}

View 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);
}
}

View 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());
}
}

View 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),
],
];
}
}

View file

@ -21,6 +21,7 @@ public function toArray(Request $request): array
'is_valid' => $this->is_valid, 'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate, 'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status, 'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(), 'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by, 'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(), 'fetched_at' => $this->fetched_at?->toISOString(),

View file

@ -0,0 +1,64 @@
<?php
namespace App\Listeners;
use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishApprovedArticleListener implements ShouldQueue
{
public string $queue = 'publishing';
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService
) {}
public function handle(ArticleApproved $event): void
{
$article = $event->article->fresh();
// Skip if already published
if ($article->articlePublication()->exists()) {
return;
}
// Skip if not approved (safety check)
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData);
if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']);
logger()->info('Published approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$article->update(['publish_status' => 'error']);
logger()->warning('No publications created for approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
}
} catch (Exception $e) {
$article->update(['publish_status' => 'error']);
logger()->error('Failed to publish approved article', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Listeners;
use App\Events\NewArticleFetched;
use App\Models\Setting;
use App\Services\Article\ValidationService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class ValidateArticleListener implements ShouldQueue
{
public string $queue = 'default';
public function __construct(
private ValidationService $validationService
) {}
public function handle(NewArticleFetched $event): void
{
$article = $event->article;
if (! is_null($article->validated_at)) {
return;
}
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) {
return;
}
try {
$article = $this->validationService->validate($article);
} catch (Exception $e) {
logger()->error('Article validation failed', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
return;
}
if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection)
if ($article->articlePublication()->exists()) {
return;
}
// If approvals are enabled, article waits for manual approval.
// If approvals are disabled, auto-approve and publish.
if (! Setting::isPublishingApprovalsEnabled()) {
$article->approve();
}
}
}
}

61
app/Livewire/Articles.php Normal file
View 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
View 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');
}
}

View 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
View 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');
}
}

465
app/Livewire/Onboarding.php Normal file
View file

@ -0,0 +1,465 @@
<?php
namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\SyncChannelPostsJob;
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, channel, feed, 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;
private ?int $previousChannelLanguageId = null;
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 = [];
// When entering feed step, inherit language from channel
if ($this->step === 4 && $this->channelLanguageId) {
$this->feedLanguageId = $this->channelLanguageId;
}
}
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 {
// Authenticate with Lemmy API first (before creating any records)
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Only create platform instance after successful authentication
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => 'lemmy',
], [
'name' => ucfirst($this->instanceUrl),
'is_active' => true,
]);
// 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;
// Get available provider codes for validation
$availableProviders = collect($this->getProvidersForLanguage())->pluck('code')->implode(',');
$this->validate([
'feedName' => 'required|string|max:255',
'feedProvider' => "required|in:{$availableProviders}",
'feedLanguageId' => 'required|exists:languages,id',
'feedDescription' => 'nullable|string|max:1000',
]);
try {
// Get language short code
$language = Language::find($this->feedLanguageId);
$langCode = $language->short_code;
// Look up URL from config
$url = config("feed.providers.{$this->feedProvider}.languages.{$langCode}.url");
if (!$url) {
$this->formErrors['general'] = 'Invalid provider and language combination.';
$this->isLoading = false;
return;
}
$providerConfig = config("feed.providers.{$this->feedProvider}");
Feed::firstOrCreate(
['url' => $url],
[
'name' => $this->feedName,
'type' => $providerConfig['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',
]);
// If language changed, reset feed form
if ($this->previousChannelLanguageId !== null && $this->previousChannelLanguageId !== $this->channelLanguageId) {
$this->feedName = '';
$this->feedProvider = '';
$this->feedDescription = '';
$this->routeFeedId = null;
$this->routeChannelId = null;
}
$this->previousChannelLanguageId = $this->channelLanguageId;
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(),
]);
// Sync existing posts from this channel for duplicate detection
SyncChannelPostsJob::dispatch($channel);
$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'));
}
/**
* Get language codes that have at least one active provider.
*/
public function getAvailableLanguageCodes(): array
{
$providers = config('feed.providers', []);
$languageCodes = [];
foreach ($providers as $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true;
}
}
return array_keys($languageCodes);
}
/**
* Get providers available for the current channel language.
*/
public function getProvidersForLanguage(): array
{
if (!$this->channelLanguageId) {
return [];
}
$language = Language::find($this->channelLanguageId);
if (!$language) {
return [];
}
$langCode = $language->short_code;
$providers = config('feed.providers', []);
$available = [];
foreach ($providers as $key => $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
if (isset($provider['languages'][$langCode])) {
$available[] = [
'code' => $provider['code'],
'name' => $provider['name'],
'description' => $provider['description'] ?? '',
];
}
}
return $available;
}
/**
* Get the current channel language model.
*/
public function getChannelLanguage(): ?Language
{
if (!$this->channelLanguageId) {
return null;
}
return Language::find($this->channelLanguageId);
}
public function render()
{
// For channel step: only show languages that have providers
$availableCodes = $this->getAvailableLanguageCodes();
$wizardLanguages = Language::where('is_active', true)
->whereIn('short_code', $availableCodes)
->orderBy('name')
->get();
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
$feeds = Feed::with('language')->where('is_active', true)->orderBy('name')->get();
$channels = PlatformChannel::with('language')->where('is_active', true)->orderBy('name')->get();
// For feed step: only show providers for the channel's language
$feedProviders = collect($this->getProvidersForLanguage());
// Get channel language for display
$channelLanguage = $this->getChannelLanguage();
return view('livewire.onboarding', [
'wizardLanguages' => $wizardLanguages,
'platformInstances' => $platformInstances,
'feeds' => $feeds,
'channels' => $channels,
'feedProviders' => $feedProviders,
'channelLanguage' => $channelLanguage,
])->layout('layouts.onboarding');
}
}

200
app/Livewire/Routes.php Normal file
View 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
View 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');
}
}

View file

@ -40,6 +40,8 @@ class Article extends Model
'published_at', 'published_at',
'author', 'author',
'approval_status', 'approval_status',
'validated_at',
'publish_status',
]; ];
/** /**
@ -49,7 +51,9 @@ public function casts(): array
{ {
return [ return [
'approval_status' => 'string', 'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime', 'published_at' => 'datetime',
'validated_at' => 'datetime',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];
@ -57,9 +61,8 @@ public function casts(): array
public function isValid(): bool public function isValid(): bool
{ {
// In the consolidated schema, we only have approval_status // Article is valid if it passed validation and wasn't rejected
// Consider 'approved' status as valid return $this->validated_at !== null && ! $this->isRejected();
return $this->approval_status === 'approved';
} }
public function isApproved(): bool public function isApproved(): bool
@ -109,6 +112,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>
*/ */
@ -125,10 +133,8 @@ public function feed(): BelongsTo
return $this->belongsTo(Feed::class); return $this->belongsTo(Feed::class);
} }
protected static function booted(): void public function dispatchFetchedEvent(): void
{ {
static::created(function ($article) { event(new NewArticleFetched($this));
event(new NewArticleFetched($article));
});
} }
} }

View file

@ -42,6 +42,25 @@ public static function urlExists(PlatformEnum $platform, string $channelId, stri
->exists(); ->exists();
} }
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
{
if (!$url && !$title) {
return false;
}
return self::where('platform', $platform)
->where('channel_id', $channelId)
->where(function ($query) use ($url, $title) {
if ($url) {
$query->orWhere('url', $url);
}
if ($title) {
$query->orWhere('title', $title);
}
})
->exists();
}
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
{ {
return self::updateOrCreate( return self::updateOrCreate(

View file

@ -52,7 +52,7 @@ public static function setArticleProcessingEnabled(bool $enabled): void
public static function isPublishingApprovalsEnabled(): bool public static function isPublishingApprovalsEnabled(): bool
{ {
return static::getBool('enable_publishing_approvals', false); return static::getBool('enable_publishing_approvals', true);
} }
public static function setPublishingApprovalsEnabled(bool $enabled): void public static function setPublishingApprovalsEnabled(bool $enabled): void

View file

@ -63,15 +63,22 @@ 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)) {
continue; continue;
} }
return null; // Connection failed - throw exception to distinguish from auth failure
throw new Exception('Connection failed: ' . $e->getMessage());
} }
} }
// If we get here without a token, it's an auth failure (not connection)
return null; return null;
} }

View file

@ -28,13 +28,30 @@ public function __construct(PlatformAccount $account)
*/ */
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
{ {
$token = resolve(LemmyAuthService::class)->getToken($this->account); $authService = resolve(LemmyAuthService::class);
$token = $authService->getToken($this->account);
// Use the language ID from extracted data (should be set during validation) try {
return $this->createPost($token, $extractedData, $channel, $article);
} catch (Exception $e) {
// If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article);
}
throw $e;
}
}
/**
* @param array<string, mixed> $extractedData
* @return array<string, mixed>
*/
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
{
$languageId = $extractedData['language_id'] ?? null; $languageId = $extractedData['language_id'] ?? null;
// Resolve community name to numeric ID if needed $communityId = is_numeric($channel->channel_id)
$communityId = is_numeric($channel->channel_id)
? (int) $channel->channel_id ? (int) $channel->channel_id
: $this->api->getCommunityId($channel->channel_id, $token); : $this->api->getCommunityId($channel->channel_id, $token);

View file

@ -30,6 +30,10 @@ public function boot(): void
\App\Listeners\ValidateArticleListener::class, \App\Listeners\ValidateArticleListener::class,
); );
Event::listen(
\App\Events\ArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
);
app()->make(ExceptionHandler::class) app()->make(ExceptionHandler::class)
->reportable(function (Throwable $e) { ->reportable(function (Throwable $e) {

View file

@ -103,27 +103,27 @@ public function fetchArticleData(Article $article): array
private function saveArticle(string $url, ?int $feedId = null): Article private function saveArticle(string $url, ?int $feedId = null): Article
{ {
$existingArticle = Article::where('url', $url)->first();
if ($existingArticle) {
return $existingArticle;
}
// Extract a basic title from URL as fallback
$fallbackTitle = $this->generateFallbackTitle($url); $fallbackTitle = $this->generateFallbackTitle($url);
try { try {
return Article::create([ $article = Article::firstOrCreate(
'url' => $url, ['url' => $url],
'feed_id' => $feedId, [
'title' => $fallbackTitle, 'feed_id' => $feedId,
]); 'title' => $fallbackTitle,
]
);
if ($article->wasRecentlyCreated) {
$article->dispatchFetchedEvent();
}
return $article;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logSaver->error("Failed to create article - title validation failed", null, [ $this->logSaver->error("Failed to create article", null, [
'url' => $url, 'url' => $url,
'feed_id' => $feedId, 'feed_id' => $feedId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'suggestion' => 'Check regex parsing patterns for title extraction'
]); ]);
throw $e; throw $e;
} }
@ -134,12 +134,12 @@ private function generateFallbackTitle(string $url): string
// Extract filename from URL as a basic fallback title // Extract filename from URL as a basic fallback title
$path = parse_url($url, PHP_URL_PATH); $path = parse_url($url, PHP_URL_PATH);
$filename = basename($path ?: $url); $filename = basename($path ?: $url);
// Remove file extension and convert to readable format // Remove file extension and convert to readable format
$title = preg_replace('/\.[^.]*$/', '', $filename); $title = preg_replace('/\.[^.]*$/', '', $filename);
$title = str_replace(['-', '_'], ' ', $title); $title = str_replace(['-', '_'], ' ', $title);
$title = ucwords($title); $title = ucwords($title);
return $title ?: 'Untitled Article'; return $title ?: 'Untitled Article';
} }
} }

View file

@ -15,7 +15,7 @@ public function validate(Article $article): Article
logger('Checking keywords for article: ' . $article->id); logger('Checking keywords for article: ' . $article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description) // Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
@ -24,23 +24,29 @@ public function validate(Article $article): Article
$updateData['description'] = $articleData['description'] ?? $article->description; $updateData['description'] = $articleData['description'] ?? $article->description;
$updateData['content'] = $articleData['full_article'] ?? null; $updateData['content'] = $articleData['full_article'] ?? null;
} }
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [ logger()->warning('Article data missing full_article content', [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url 'url' => $article->url
]); ]);
$updateData['approval_status'] = 'rejected'; $updateData['approval_status'] = 'rejected';
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
} }
// Validate using extracted content (not stored)
$validationResult = $this->validateByKeywords($articleData['full_article']);
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
// Validate content against keywords. If validation fails, reject.
// If validation passes, leave approval_status as-is (pending) —
// the listener decides whether to auto-approve based on settings.
$validationResult = $this->validateByKeywords($articleData['full_article']);
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now();
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
@ -53,12 +59,12 @@ private function validateByKeywords(string $full_article): bool
// Political parties and leaders // Political parties and leaders
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
// Belgian locations and institutions // Belgian locations and institutions
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
'parliament', 'government', 'minister', 'policy', 'law', 'legislation', 'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
// Common Belgian news topics // Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'

View file

@ -14,6 +14,22 @@ class LemmyAuthService
* @throws PlatformAuthException * @throws PlatformAuthException
*/ */
public function getToken(PlatformAccount $account): string public function getToken(PlatformAccount $account): string
{
// Use cached token if available
$cachedToken = $account->settings['api_token'] ?? null;
if ($cachedToken) {
return $cachedToken;
}
return $this->refreshToken($account);
}
/**
* Clear cached token and re-authenticate.
*
* @throws PlatformAuthException
*/
public function refreshToken(PlatformAccount $account): string
{ {
if (! $account->username || ! $account->password || ! $account->instance_url) { if (! $account->username || ! $account->password || ! $account->instance_url) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username);
@ -26,6 +42,11 @@ public function getToken(PlatformAccount $account): string
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username);
} }
// Cache the token for future use
$settings = $account->settings ?? [];
$settings['api_token'] = $token;
$account->update(['settings' => $settings]);
return $token; return $token;
} }
@ -65,6 +86,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
if (str_contains($e->getMessage(), 'Rate limited by')) { if (str_contains($e->getMessage(), 'Rate limited by')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage()); throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
} }
// Check if it's a connection failure
if (str_contains($e->getMessage(), 'Connection failed')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}
// For other exceptions, throw a clean PlatformAuthException // For other exceptions, throw a clean PlatformAuthException
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed'); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
} }

Some files were not shown because too many files have changed in this diff Show more