Compare commits

..

7 commits

Author SHA1 Message Date
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
378 changed files with 6778 additions and 27918 deletions

3
.gitignore vendored
View file

@ -23,5 +23,6 @@ yarn-error.log
/.nova
/.vscode
/.zed
/backend/coverage-report*
/coverage-report*
/coverage.xml
/.claude

127
Dockerfile Normal file
View 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
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"]

291
README.md
View file

@ -1,219 +1,128 @@
# Fedi Feed Router (FFR) v1.0.0
# FFR (Feed to Fediverse Router)
<div align="center">
<img src="backend/public/images/ffr-logo-600.png" alt="FFR Logo" width="200">
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.
**A minimal working version — limited to two hardcoded sources, designed for self-hosters.**
*Future versions will expand configurability and support.*
</div>
## Features
---
- **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:
- ✅ Fetches articles from two hardcoded RSS feeds (CBC News, BBC News)
- ✅ Keyword-based content filtering and matching
- ✅ Automatic posting to Lemmy communities
- ✅ Web dashboard for monitoring and management
- ✅ Docker-based deployment for easy self-hosting
- ✅ Privacy-first design with no external dependencies
db:
image: mariadb:11
container_name: ffr_db
restart: always
environment:
MYSQL_DATABASE: "${DB_DATABASE}"
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):
- Feed sources are currently hardcoded (not user-configurable)
- Only supports Lemmy as target platform
- Basic keyword matching (no regex or complex rules yet)
redis:
image: redis:7-alpine
container_name: ffr_redis
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
git clone https://codeberg.org/lvl0/ffr.git
cd ffr
nix-shell
```
2. **Create environment file:**
```bash
cp docker/production/.env.example .env
```
The shell will display available commands and optionally start the containers for you.
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
```
#### Available Commands
4. **Start the application:**
```bash
docker-compose -f docker/production/docker-compose.yml up -d
```
| 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 |
| `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)
- 2GB RAM minimum
- 10GB disk space
- Linux/macOS/Windows with WSL2
### Other Platforms
## 🕹️ Usage
Contributions welcome for development setup instructions on other platforms.
### Web Interface
## License
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:
```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
---
This project is open-source software licensed under the [AGPL-3.0 license](LICENSE).
## Support
For help and support:
- 💬 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>
For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues).

View file

@ -2,9 +2,9 @@
namespace App\Console\Commands;
use Domains\Article\Jobs\ArticleDiscoveryJob;
use Domains\Feed\Models\Feed;
use Domains\Settings\Models\Setting;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Feed;
use App\Models\Setting;
use Illuminate\Console\Command;
class FetchNewArticlesCommand extends Command

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands;
use Domains\Platform\Jobs\SyncChannelPostsJob;
use App\Jobs\SyncChannelPostsJob;
use Illuminate\Console\Command;
class SyncChannelPostsCommand extends Command

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Article\Contracts;
namespace App\Contracts;
interface ArticleParserInterface
{

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Article\Contracts;
namespace App\Contracts;
interface HomepageParserInterface
{

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Logging\Enums;
namespace App\Enums;
enum LogLevelEnum: string
{

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Platform\Enums;
namespace App\Enums;
enum PlatformEnum: string
{

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Article\Events;
namespace App\Events;
use Domains\Article\Models\Article;
use App\Models\Article;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Logging\Events;
namespace App\Events;
use Domains\Logging\Models\Log;
use App\Models\Log;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Logging\Events;
namespace App\Events;
use Domains\Logging\Enums\LogLevelEnum;
use App\Enums\LogLevelEnum;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Throwable;

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Article\Events;
namespace App\Events;
use Domains\Article\Models\Article;
use App\Models\Article;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class ChannelException extends Exception
{
}

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Platform\Exceptions;
namespace App\Exceptions;
use Domains\Platform\Enums\PlatformEnum;
use App\Enums\PlatformEnum;
use Exception;
class PlatformAuthException extends Exception

View file

@ -1,9 +1,9 @@
<?php
namespace Domains\Platform\Exceptions;
namespace App\Exceptions;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Article\Models\Article;
use App\Enums\PlatformEnum;
use App\Models\Article;
use Exception;
use Throwable;

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Feed\Exceptions;
namespace App\Exceptions;
use Exception;

View file

@ -1,9 +1,9 @@
<?php
namespace Domains\Feed\Exceptions;
namespace App\Exceptions;
use Domains\Feed\Models\Feed;
use Domains\Platform\Models\PlatformChannel;
use App\Models\Feed;
use App\Models\PlatformChannel;
class RoutingMismatchException extends RoutingException
{

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Logging\Facades;
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
@ -8,6 +8,6 @@ class LogSaver extends Facade
{
protected static function getFacadeAccessor()
{
return \Domains\Logging\Services\LogSaver::class;
return \App\Services\Log\LogSaver::class;
}
}

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Article\Resources\ArticleResource;
use Domains\Article\Models\Article;
use Domains\Settings\Models\Setting;
use Domains\Article\Jobs\ArticleDiscoveryJob;
use App\Http\Resources\ArticleResource;
use App\Models\Article;
use App\Models\Setting;
use App\Jobs\ArticleDiscoveryJob;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View file

@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends BaseController
{
/**
* Login user and create token
*/
public function login(Request $request): JsonResponse
{
try {
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return $this->sendError('Invalid credentials', [], 401);
}
$token = $user->createToken('api-token')->plainTextToken;
return $this->sendResponse([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
'token_type' => 'Bearer',
], 'Login successful');
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Login failed: ' . $e->getMessage(), [], 500);
}
}
/**
* Register a new user
*/
public function register(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('api-token')->plainTextToken;
return $this->sendResponse([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
'token_type' => 'Bearer',
], 'Registration successful', 201);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500);
}
}
/**
* Logout user (revoke token)
*/
public function logout(Request $request): JsonResponse
{
try {
$request->user()->currentAccessToken()->delete();
return $this->sendResponse(null, 'Logged out successfully');
} catch (\Exception $e) {
return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500);
}
}
/**
* Get current authenticated user
*/
public function me(Request $request): JsonResponse
{
return $this->sendResponse([
'user' => [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
],
], 'User retrieved successfully');
}
}

View file

@ -2,11 +2,11 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Article\Models\Article;
use Domains\Feed\Models\Feed;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel;
use Domains\Article\Services\DashboardStatsService;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Services\DashboardStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -40,6 +40,7 @@ public function stats(Request $request): JsonResponse
'current_period' => $period,
]);
} catch (\Exception $e) {
throw $e;
return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500);
}
}

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Requests\StoreFeedRequest;
use Domains\Feed\Requests\UpdateFeedRequest;
use Domains\Feed\Resources\FeedResource;
use Domains\Feed\Models\Feed;
use App\Http\Requests\StoreFeedRequest;
use App\Http\Requests\UpdateFeedRequest;
use App\Http\Resources\FeedResource;
use App\Models\Feed;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -19,7 +19,7 @@ public function index(Request $request): JsonResponse
{
$perPage = min($request->get('per_page', 15), 100);
$feeds = Feed::with(['languages'])
$feeds = Feed::with(['language'])
->withCount('articles')
->orderBy('is_active', 'desc')
->orderBy('name')
@ -49,36 +49,16 @@ public function store(StoreFeedRequest $request): JsonResponse
// Map provider to URL and set type
$providers = [
'vrt' => new \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter(),
'belga' => new \Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter(),
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
];
$adapter = $providers[$validated['provider']];
$validated['url'] = $adapter->getHomepageUrl();
$validated['type'] = 'website';
// Extract language-related data
$languageIds = $validated['language_ids'] ?? [];
$primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0] ?? null;
$languageUrls = $validated['language_urls'] ?? [];
// Remove language fields from feed data
unset($validated['language_ids'], $validated['primary_language_id'], $validated['language_urls']);
$feed = Feed::create($validated);
// Attach languages to the feed
foreach ($languageIds as $index => $languageId) {
$pivotData = [
'url' => $languageUrls[$languageId] ?? $validated['url'],
'is_active' => true,
'is_primary' => $languageId == $primaryLanguageId,
];
$feed->languages()->attach($languageId, $pivotData);
}
$feed->load('languages');
return $this->sendResponse(
new FeedResource($feed),
'Feed created successfully!',
@ -96,8 +76,6 @@ public function store(StoreFeedRequest $request): JsonResponse
*/
public function show(Feed $feed): JsonResponse
{
$feed->load('languages');
return $this->sendResponse(
new FeedResource($feed),
'Feed retrieved successfully.'
@ -113,34 +91,10 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
$validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? $feed->is_active;
// Extract language-related data
$languageIds = $validated['language_ids'] ?? null;
$primaryLanguageId = $validated['primary_language_id'] ?? null;
$languageUrls = $validated['language_urls'] ?? [];
// Remove language fields from feed data
unset($validated['language_ids'], $validated['primary_language_id'], $validated['language_urls']);
$feed->update($validated);
// Update languages if provided
if ($languageIds !== null) {
// Sync languages with the feed
$syncData = [];
foreach ($languageIds as $index => $languageId) {
$syncData[$languageId] = [
'url' => $languageUrls[$languageId] ?? $feed->url,
'is_active' => true,
'is_primary' => $primaryLanguageId ? $languageId == $primaryLanguageId : $index === 0,
];
}
$feed->languages()->sync($syncData);
}
$feed->load('languages');
return $this->sendResponse(
new FeedResource($feed),
new FeedResource($feed->fresh()),
'Feed updated successfully!'
);
} catch (ValidationException $e) {

View file

@ -2,9 +2,9 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Models\Feed;
use Domains\Article\Models\Keyword;
use Domains\Platform\Models\PlatformChannel;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

View file

@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Logging\Models\Log;
use App\Models\Log;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View file

@ -2,22 +2,20 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Requests\StoreFeedRequest;
use Domains\Feed\Resources\FeedResource;
use Domains\Platform\Resources\PlatformAccountResource;
use Domains\Platform\Resources\PlatformChannelResource;
use Domains\Feed\Resources\RouteResource;
use Domains\Article\Jobs\ArticleDiscoveryJob;
use Domains\Feed\Models\Feed;
use Domains\Settings\Models\Language;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel;
use Domains\Platform\Models\PlatformInstance;
use Domains\Feed\Models\Route;
use Domains\Settings\Models\Setting;
use Domains\Platform\Services\Auth\Authenticators\LemmyAuthService;
use Domains\Platform\Services\ChannelLanguageDetectionService;
use Domains\Platform\Exceptions\ChannelException;
use App\Http\Requests\StoreFeedRequest;
use App\Http\Resources\FeedResource;
use App\Http\Resources\PlatformAccountResource;
use App\Http\Resources\PlatformChannelResource;
use App\Http\Resources\RouteResource;
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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
@ -91,19 +89,13 @@ public function options(): JsonResponse
// Get existing feeds and channels for route creation
$feeds = Feed::where('is_active', true)
->with('languages')
->orderBy('name')
->get(['id', 'name', 'url', 'type']);
$platformChannels = PlatformChannel::where('is_active', true)
->with(['platformInstance:id,name,url', 'language:id,name,short_code'])
->with(['platformInstance:id,name,url'])
->orderBy('name')
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']);
// Get languages available for routes (have both feeds and channels)
$availableLanguages = Language::availableForRoutes()
->orderBy('name')
->get(['id', 'short_code', 'name', 'native_name']);
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
// Get feed providers from config
$feedProviders = collect(config('feed.providers', []))
@ -115,7 +107,6 @@ public function options(): JsonResponse
'platform_instances' => $platformInstances,
'feeds' => $feeds,
'platform_channels' => $platformChannels,
'available_languages' => $availableLanguages,
'feed_providers' => $feedProviders,
], 'Onboarding options retrieved successfully.');
}
@ -183,7 +174,7 @@ public function createPlatform(Request $request): JsonResponse
'Platform account created successfully.'
);
} catch (\Domains\Platform\Exceptions\PlatformAuthException $e) {
} catch (\App\Exceptions\PlatformAuthException $e) {
// Check if it's a rate limit error
if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429);
@ -213,9 +204,7 @@ public function createFeed(Request $request): JsonResponse
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'provider' => 'required|in:belga,vrt',
'language_ids' => 'required|array|min:1',
'language_ids.*' => 'exists:languages,id',
'primary_language_id' => 'nullable|exists:languages,id',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000',
]);
@ -235,34 +224,20 @@ public function createFeed(Request $request): JsonResponse
$url = 'https://www.belganewsagency.eu/';
}
// Extract language-related data
$languageIds = $validated['language_ids'];
$primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0];
$feed = Feed::firstOrCreate(
['url' => $url],
[
'name' => $validated['name'],
'type' => $type,
'provider' => $provider,
'language_id' => $validated['language_id'],
'description' => $validated['description'] ?? null,
'is_active' => true,
]
);
// Sync languages with the feed
$syncData = [];
foreach ($languageIds as $languageId) {
$syncData[$languageId] = [
'url' => $url,
'is_active' => true,
'is_primary' => $languageId == $primaryLanguageId,
];
}
$feed->languages()->sync($syncData);
return $this->sendResponse(
new FeedResource($feed->load('languages')),
new FeedResource($feed->load('language')),
'Feed created successfully.'
);
}
@ -276,6 +251,7 @@ public function createChannel(Request $request): JsonResponse
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'platform_instance_id' => 'required|exists:platform_instances,id',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000',
]);
@ -301,22 +277,6 @@ public function createChannel(Request $request): JsonResponse
);
}
// Detect and validate channel languages
$languageDetectionService = app(ChannelLanguageDetectionService::class);
try {
$languageInfo = $languageDetectionService->detectChannelLanguages(
$validated['name'],
$validated['platform_instance_id']
);
// Add detected language to validated data
$validated['language_id'] = $languageInfo['language_id'];
} catch (ChannelException $e) {
return $this->sendError($e->getMessage(), [], 422);
}
$channel = PlatformChannel::create([
'platform_instance_id' => $validated['platform_instance_id'],
'channel_id' => $validated['name'], // For Lemmy, this is the community name
@ -336,16 +296,9 @@ public function createChannel(Request $request): JsonResponse
'updated_at' => now(),
]);
$responseMessage = 'Channel created successfully and linked to platform account.';
// Add information about language detection if fallback was used
if (isset($languageInfo['fallback_used']) && $languageInfo['fallback_used']) {
$responseMessage .= ' Note: Used default language due to detection issue.';
}
return $this->sendResponse(
new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])),
$responseMessage
'Channel created successfully and linked to platform account.'
);
}
@ -359,7 +312,6 @@ public function createRoute(Request $request): JsonResponse
$validator = Validator::make($request->all(), [
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'language_id' => 'required|exists:languages,id',
'priority' => 'nullable|integer|min:1|max:100',
]);
@ -369,22 +321,9 @@ public function createRoute(Request $request): JsonResponse
$validated = $validator->validated();
// Validate language consistency
$feed = Feed::find($validated['feed_id']);
$channel = PlatformChannel::find($validated['platform_channel_id']);
if (!$feed || !$feed->languages()->where('languages.id', $validated['language_id'])->exists()) {
return $this->sendError('The selected feed does not support the chosen language.', [], 422);
}
if (!$channel || $channel->language_id !== (int)$validated['language_id']) {
return $this->sendError('The selected channel does not support the chosen language.', [], 422);
}
$route = Route::create([
'feed_id' => $validated['feed_id'],
'platform_channel_id' => $validated['platform_channel_id'],
'language_id' => $validated['language_id'],
'priority' => $validated['priority'] ?? 50,
'is_active' => true,
]);
@ -394,7 +333,7 @@ public function createRoute(Request $request): JsonResponse
ArticleDiscoveryJob::dispatch();
return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel', 'language'])),
new RouteResource($route->load(['feed', 'platformChannel'])),
'Route created successfully.'
);
}

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Platform\Resources\PlatformAccountResource;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformInstance;
use App\Enums\PlatformEnum;
use App\Http\Resources\PlatformAccountResource;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

View file

@ -2,17 +2,12 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Platform\Resources\PlatformChannelResource;
use Domains\Platform\Models\PlatformChannel;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformInstance;
use Domains\Platform\Services\ChannelLanguageDetectionService;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use Domains\Platform\Exceptions\ChannelException;
use App\Http\Resources\PlatformChannelResource;
use App\Models\PlatformChannel;
use App\Models\PlatformAccount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Cache;
class PlatformChannelsController extends BaseController
{
@ -50,7 +45,7 @@ public function store(Request $request): JsonResponse
$validated['is_active'] = $validated['is_active'] ?? true;
// Get the platform instance to check for active accounts
$platformInstance = \Domains\Platform\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
$platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
@ -65,22 +60,6 @@ public function store(Request $request): JsonResponse
);
}
// Detect and validate channel languages
$languageDetectionService = app(ChannelLanguageDetectionService::class);
try {
$languageInfo = $languageDetectionService->detectChannelLanguages(
$validated['name'],
$validated['platform_instance_id']
);
// Add detected language to validated data
$validated['language_id'] = $languageInfo['language_id'];
} catch (ChannelException $e) {
return $this->sendError($e->getMessage(), [], 422);
}
$channel = PlatformChannel::create($validated);
// Automatically attach the first active account to the channel
@ -92,16 +71,9 @@ public function store(Request $request): JsonResponse
'updated_at' => now(),
]);
$responseMessage = 'Platform channel created successfully and linked to platform account!';
// Add information about language detection if fallback was used
if (isset($languageInfo['fallback_used']) && $languageInfo['fallback_used']) {
$responseMessage .= ' Note: Used default language due to detection issue.';
}
return $this->sendResponse(
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts', 'language'])),
$responseMessage,
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])),
'Platform channel created successfully and linked to platform account!',
201
);
} catch (ValidationException $e) {
@ -274,108 +246,4 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
}
}
/**
* Get available communities for a platform instance
*/
public function getCommunities(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'platform_instance_id' => 'required|exists:platform_instances,id',
'type' => 'sometimes|string|in:Local,All,Subscribed',
'sort' => 'sometimes|string|in:Hot,Active,New,TopDay,TopWeek,TopMonth,TopYear,TopAll',
'limit' => 'sometimes|integer|min:1|max:100',
'page' => 'sometimes|integer|min:1',
'show_nsfw' => 'sometimes|boolean',
]);
$platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance to get auth token
$activeAccount = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->first();
if (!$activeAccount) {
return $this->sendError(
'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.',
[],
422
);
}
// Create cache key based on instance and parameters
$cacheKey = sprintf(
'communities:%s:%s:%s:%d:%d:%s',
$platformInstance->id,
$validated['type'] ?? 'Local',
$validated['sort'] ?? 'Active',
$validated['limit'] ?? 50,
$validated['page'] ?? 1,
$validated['show_nsfw'] ?? false ? '1' : '0'
);
// Try to get communities from cache first (cache for 10 minutes)
$communities = Cache::remember($cacheKey, 600, function () use ($platformInstance, $activeAccount, $validated) {
$apiService = app(LemmyApiService::class, ['instance' => $platformInstance->url]);
return $apiService->listCommunities(
$activeAccount->settings['api_token'] ?? null,
$validated['type'] ?? 'Local',
$validated['sort'] ?? 'Active',
$validated['limit'] ?? 50,
$validated['page'] ?? 1,
$validated['show_nsfw'] ?? false
);
});
// Transform the response to include only relevant data and add helpful fields
$transformedCommunities = collect($communities['communities'] ?? [])->map(function ($item) {
$community = $item['community'] ?? [];
return [
'id' => $community['id'] ?? null,
'name' => $community['name'] ?? null,
'title' => $community['title'] ?? null,
'description' => $community['description'] ?? null,
'nsfw' => $community['nsfw'] ?? false,
'local' => $community['local'] ?? false,
'subscribers' => $item['counts']['subscribers'] ?? 0,
'posts' => $item['counts']['posts'] ?? 0,
'display_text' => sprintf(
'%s (%s subscribers)',
$community['title'] ?? $community['name'] ?? 'Unknown',
number_format($item['counts']['subscribers'] ?? 0)
),
];
});
return $this->sendResponse([
'communities' => $transformedCommunities,
'total' => $transformedCommunities->count(),
'platform_instance' => [
'id' => $platformInstance->id,
'name' => $platformInstance->name,
'url' => $platformInstance->url,
],
'parameters' => [
'type' => $validated['type'] ?? 'Local',
'sort' => $validated['sort'] ?? 'Active',
'limit' => $validated['limit'] ?? 50,
'page' => $validated['page'] ?? 1,
'show_nsfw' => $validated['show_nsfw'] ?? false,
]
], 'Communities retrieved successfully.');
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
// Clear cache on error to prevent serving stale data
if (isset($cacheKey)) {
Cache::forget($cacheKey);
}
return $this->sendError('Failed to fetch communities: ' . $e->getMessage(), [], 500);
}
}
}

View file

@ -2,28 +2,22 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Resources\RouteResource;
use Domains\Feed\Models\Feed;
use Domains\Platform\Models\PlatformChannel;
use Domains\Feed\Models\Route;
use Domains\Feed\Requests\StoreRouteRequest;
use Domains\Feed\Requests\UpdateRouteRequest;
use Domains\Settings\Services\LanguageService;
use App\Http\Resources\RouteResource;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class RoutingController extends BaseController
{
public function __construct(
private LanguageService $languageService
) {}
/**
* Display a listing of routing configurations
*/
public function index(): JsonResponse
{
$routes = Route::withAllRelationships()
$routes = Route::with(['feed', 'platformChannel', 'keywords'])
->orderBy('is_active', 'desc')
->orderBy('priority', 'asc')
->get();
@ -37,27 +31,23 @@ public function index(): JsonResponse
/**
* Store a newly created routing configuration
*/
public function store(StoreRouteRequest $request): JsonResponse
public function store(Request $request): JsonResponse
{
try {
$validated = $request->validated();
$validated = $request->validate([
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'is_active' => 'boolean',
'priority' => 'nullable|integer|min:0',
]);
$validated['is_active'] = $validated['is_active'] ?? true;
$validated['priority'] = $validated['priority'] ?? 0;
$route = Route::create($validated);
// Load relationships efficiently
$route->load([
'feed:id,name,url,type,provider,is_active',
'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active',
'platformChannel.platformInstance:id,name,url',
'language:id,short_code,name,native_name,is_active',
'keywords:id,feed_id,platform_channel_id,keyword,is_active'
]);
return $this->sendResponse(
new RouteResource($route),
new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
'Routing configuration created successfully!',
201
);
@ -79,13 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
return $this->sendNotFound('Routing configuration not found.');
}
$route->load([
'feed:id,name,url,type,provider,is_active',
'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active',
'platformChannel.platformInstance:id,name,url',
'language:id,short_code,name,native_name,is_active',
'keywords:id,feed_id,platform_channel_id,keyword,is_active'
]);
$route->load(['feed', 'platformChannel', 'keywords']);
return $this->sendResponse(
new RouteResource($route),
@ -96,7 +80,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
/**
* Update the specified routing configuration
*/
public function update(UpdateRouteRequest $request, Feed $feed, PlatformChannel $channel): JsonResponse
public function update(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
{
try {
$route = $this->findRoute($feed, $channel);
@ -105,14 +89,17 @@ public function update(UpdateRouteRequest $request, Feed $feed, PlatformChannel
return $this->sendNotFound('Routing configuration not found.');
}
$validated = $request->validated();
$validated = $request->validate([
'is_active' => 'boolean',
'priority' => 'nullable|integer|min:0',
]);
Route::where('feed_id', $feed->id)
->where('platform_channel_id', $channel->id)
->update($validated);
return $this->sendResponse(
new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])),
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
'Routing configuration updated successfully!'
);
} catch (ValidationException $e) {
@ -167,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
$status = $newStatus ? 'activated' : 'deactivated';
return $this->sendResponse(
new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])),
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
"Routing configuration {$status} successfully!"
);
} catch (\Exception $e) {
@ -175,19 +162,6 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
}
}
/**
* Get common languages for feed and channel
*/
public function commonLanguages(Feed $feed, PlatformChannel $channel): JsonResponse
{
$commonLanguages = $this->languageService->getCommonLanguages($feed->id, $channel->id);
return $this->sendResponse(
$commonLanguages,
'Common languages for feed and channel retrieved successfully.'
);
}
/**
* Find a route by feed and channel
*/

View file

@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\V1;
use Domains\Settings\Models\Setting;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

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

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFeedRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'provider' => 'required|in:vrt,belga',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string',
'is_active' => 'boolean'
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use App\Models\Feed;
use Illuminate\Foundation\Http\FormRequest;
class UpdateFeedRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')),
'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string',
'is_active' => 'boolean'
];
}
}

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Article\Resources;
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,9 +1,7 @@
<?php
namespace Domains\Article\Resources;
namespace App\Http\Resources;
use Domains\Feed\Resources\FeedResource;
use Domains\Article\Resources\ArticlePublicationResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeedResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'url' => $this->url,
'type' => $this->type,
'provider' => $this->provider,
'language_id' => $this->language_id,
'is_active' => $this->is_active,
'description' => $this->description,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'articles_count' => $this->when(
$request->routeIs('api.feeds.*') && isset($this->articles_count),
$this->articles_count
),
];
}
}

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Platform\Resources;
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,10 +1,7 @@
<?php
namespace Domains\Platform\Resources;
namespace App\Http\Resources;
use Domains\Feed\Resources\RouteResource;
use Domains\Platform\Resources\PlatformInstanceResource;
use Domains\Platform\Resources\PlatformAccountResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Platform\Resources;
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,10 +1,7 @@
<?php
namespace Domains\Feed\Resources;
namespace App\Http\Resources;
use Domains\Feed\Resources\FeedResource;
use Domains\Platform\Resources\PlatformChannelResource;
use Domains\Settings\Resources\LanguageResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@ -21,14 +18,12 @@ public function toArray(Request $request): array
'id' => $this->id,
'feed_id' => $this->feed_id,
'platform_channel_id' => $this->platform_channel_id,
'language_id' => $this->language_id,
'is_active' => $this->is_active,
'priority' => $this->priority,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'feed' => new FeedResource($this->whenLoaded('feed')),
'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')),
'language' => new LanguageResource($this->whenLoaded('language')),
'keywords' => $this->whenLoaded('keywords', function () {
return $this->keywords->map(function ($keyword) {
return [

View file

@ -1,10 +1,10 @@
<?php
namespace Domains\Article\Jobs;
namespace App\Jobs;
use Domains\Feed\Models\Feed;
use Domains\Article\Services\ArticleFetcher;
use Domains\Logging\Services\LogSaver;
use App\Models\Feed;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

View file

@ -1,9 +1,9 @@
<?php
namespace Domains\Article\Jobs;
namespace App\Jobs;
use Domains\Settings\Models\Setting;
use Domains\Logging\Services\LogSaver;
use App\Models\Setting;
use App\Services\Log\LogSaver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

View file

@ -1,11 +1,11 @@
<?php
namespace Domains\Platform\Jobs;
namespace App\Jobs;
use Domains\Platform\Exceptions\PublishException;
use Domains\Article\Models\Article;
use Domains\Article\Services\ArticleFetcher;
use Domains\Platform\Services\Publishing\ArticlePublishingService;
use App\Exceptions\PublishException;
use App\Models\Article;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

View file

@ -1,13 +1,13 @@
<?php
namespace Domains\Platform\Jobs;
namespace App\Jobs;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Platform\Exceptions\PlatformAuthException;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use Domains\Logging\Services\LogSaver;
use App\Enums\PlatformEnum;
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Modules\Lemmy\Services\LemmyApiService;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;

View file

@ -1,10 +1,10 @@
<?php
namespace Domains\Logging\Listeners;
namespace App\Listeners;
use Domains\Logging\Events\ExceptionLogged;
use Domains\Logging\Events\ExceptionOccurred;
use Domains\Logging\Models\Log;
use App\Events\ExceptionLogged;
use App\Events\ExceptionOccurred;
use App\Models\Log;
class LogExceptionToDatabase
{

View file

@ -1,11 +1,11 @@
<?php
namespace Domains\Article\Listeners;
namespace App\Listeners;
use Domains\Article\Events\NewArticleFetched;
use Domains\Article\Events\ArticleApproved;
use Domains\Settings\Models\Setting;
use Domains\Article\Services\ValidationService;
use App\Events\NewArticleFetched;
use App\Events\ArticleApproved;
use App\Models\Setting;
use App\Services\Article\ValidationService;
use Illuminate\Contracts\Queue\ShouldQueue;
class ValidateArticleListener implements ShouldQueue

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

358
app/Livewire/Onboarding.php Normal file
View 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
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

@ -1,12 +1,10 @@
<?php
namespace Domains\Article\Models;
namespace App\Models;
use Domains\Article\Events\ArticleApproved;
use Domains\Article\Events\NewArticleFetched;
use Domains\Article\Factories\ArticleFactory;
use Domains\Feed\Models\Feed;
use Domains\Settings\Models\Setting;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched;
use Database\Factories\ArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,9 +12,9 @@
use Illuminate\Support\Carbon;
/**
* @method static firstOrCreate(array $array)
* @method static firstOrCreate(array<string, mixed> $array)
* @method static where(string $string, string $url)
* @method static create(array $array)
* @method static create(array<string, mixed> $array)
* @property integer $id
* @property int $feed_id
* @property Feed $feed
@ -32,11 +30,6 @@ class Article extends Model
/** @use HasFactory<ArticleFactory> */
use HasFactory;
protected static function newFactory()
{
return ArticleFactory::new();
}
protected $fillable = [
'feed_id',
'url',
@ -108,7 +101,7 @@ public function canBePublished(): bool
}
// If approval system is disabled, auto-approve valid articles
if (!Setting::isPublishingApprovalsEnabled()) {
if (!\App\Models\Setting::isPublishingApprovalsEnabled()) {
return true;
}
@ -116,6 +109,11 @@ public function canBePublished(): bool
return $this->isApproved();
}
public function getIsPublishedAttribute(): bool
{
return $this->articlePublication()->exists();
}
/**
* @return HasOne<ArticlePublication, $this>
*/

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Article\Models;
namespace App\Models;
use Domains\Article\Factories\ArticlePublicationFactory;
use Database\Factories\ArticlePublicationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -12,18 +12,13 @@
* @property integer $platform_channel_id
* @property integer $post_id
*
* @method static create(array $array)
* @method static create(array<string, mixed> $array)
*/
class ArticlePublication extends Model
{
/** @use HasFactory<ArticlePublicationFactory> */
use HasFactory;
protected static function newFactory()
{
return \Domains\Article\Factories\ArticlePublicationFactory::new();
}
protected $fillable = [
'article_id',
'platform_channel_id',

View file

@ -1,11 +1,8 @@
<?php
namespace Domains\Feed\Models;
namespace App\Models;
use Domains\Article\Models\Article;
use Domains\Feed\Factories\FeedFactory;
use Domains\Platform\Models\PlatformChannel;
use Domains\Settings\Models\Language;
use Database\Factories\FeedFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -19,6 +16,8 @@
* @property string $url
* @property string $type
* @property string $provider
* @property int $language_id
* @property Language|null $language
* @property string $description
* @property array<string, mixed> $settings
* @property bool $is_active
@ -33,11 +32,6 @@ class Feed extends Model
{
/** @use HasFactory<FeedFactory> */
use HasFactory;
protected static function newFactory()
{
return \Domains\Feed\Factories\FeedFactory::new();
}
private const RECENT_FETCH_THRESHOLD_HOURS = 2;
private const DAILY_FETCH_THRESHOLD_HOURS = 24;
@ -46,6 +40,7 @@ protected static function newFactory()
'url',
'type',
'provider',
'language_id',
'description',
'settings',
'is_active',
@ -117,35 +112,10 @@ public function articles(): HasMany
}
/**
* @return BelongsToMany<Language, $this>
* @return BelongsTo<Language, $this>
*/
public function languages(): BelongsToMany
public function language(): BelongsTo
{
return $this->belongsToMany(Language::class, 'feed_languages')
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps()
->wherePivot('is_active', true)
->orderByPivot('is_primary', 'desc');
}
/**
* Get the primary language for this feed
* @return Language|null
*/
public function getPrimaryLanguageAttribute(): ?Language
{
return $this->languages()->wherePivot('is_primary', true)->first();
}
/**
* Get all languages including inactive ones
* @return BelongsToMany<Language, $this>
*/
public function allLanguages(): BelongsToMany
{
return $this->belongsToMany(Language::class, 'feed_languages')
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps()
->orderByPivot('is_primary', 'desc');
return $this->belongsTo(Language::class);
}
}

View file

@ -1,9 +1,7 @@
<?php
namespace Domains\Article\Models;
namespace App\Models;
use Domains\Feed\Models\Feed;
use Domains\Platform\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -24,11 +22,6 @@ class Keyword extends Model
{
use HasFactory;
protected static function newFactory()
{
return \Domains\Article\Factories\KeywordFactory::new();
}
protected $fillable = [
'feed_id',
'platform_channel_id',

52
app/Models/Language.php Normal file
View file

@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Database\Factories\LanguageFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Language extends Model
{
/** @use HasFactory<LanguageFactory> */
use HasFactory;
protected $fillable = [
'short_code',
'name',
'native_name',
'is_active'
];
protected $casts = [
'is_active' => 'boolean'
];
/**
* @return BelongsToMany<PlatformInstance, $this>
*/
public function platformInstances(): BelongsToMany
{
return $this->belongsToMany(PlatformInstance::class)
->withPivot(['platform_language_id', 'is_default'])
->withTimestamps();
}
/**
* @return HasMany<PlatformChannel, $this>
*/
public function platformChannels(): HasMany
{
return $this->hasMany(PlatformChannel::class);
}
/**
* @return HasMany<Feed, $this>
*/
public function feeds(): HasMany
{
return $this->hasMany(Feed::class);
}
}

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Logging\Models;
namespace App\Models;
use Domains\Logging\Enums\LogLevelEnum;
use App\Enums\LogLevelEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@ -19,11 +19,6 @@ class Log extends Model
{
use HasFactory;
protected static function newFactory()
{
return \Domains\Logging\Factories\LogFactory::new();
}
protected $table = 'logs';
protected $fillable = [

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Platform\Models;
namespace App\Models;
use Domains\Platform\Factories\PlatformAccountFactory;
use Database\Factories\PlatformAccountFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -10,7 +10,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt;
use Domains\Platform\Enums\PlatformEnum;
use App\Enums\PlatformEnum;
/**
* @property int $id
@ -27,18 +27,13 @@
* @property Collection<int, PlatformChannel> $activeChannels
* @method static where(string $string, PlatformEnum $platform)
* @method static orderBy(string $string)
* @method static create(array $validated)
* @method static create(array<string, mixed> $validated)
*/
class PlatformAccount extends Model
{
/** @use HasFactory<PlatformAccountFactory> */
use HasFactory;
protected static function newFactory()
{
return \Domains\Platform\Factories\PlatformAccountFactory::new();
}
protected $fillable = [
'platform',
'instance_url',

View file

@ -1,10 +1,8 @@
<?php
namespace Domains\Platform\Models;
namespace App\Models;
use Domains\Feed\Models\Feed;
use Domains\Platform\Factories\PlatformChannelFactory;
use Domains\Settings\Models\Language;
use Database\Factories\PlatformChannelFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -27,11 +25,6 @@ class PlatformChannel extends Model
/** @use HasFactory<PlatformChannelFactory> */
use HasFactory;
protected static function newFactory()
{
return \Domains\Platform\Factories\PlatformChannelFactory::new();
}
protected $table = 'platform_channels';
protected $fillable = [

View file

@ -1,24 +1,18 @@
<?php
namespace Domains\Platform\Models;
namespace App\Models;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Platform\Factories\PlatformChannelPostFactory;
use App\Enums\PlatformEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @method static where(string $string, PlatformEnum $platform)
* @method static updateOrCreate(array $array, array $array1)
* @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
*/
class PlatformChannelPost extends Model
{
use HasFactory;
protected static function newFactory()
{
return PlatformChannelPostFactory::new();
}
protected $fillable = [
'platform',
'channel_id',

View file

@ -1,17 +1,16 @@
<?php
namespace Domains\Platform\Models;
namespace App\Models;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Platform\Factories\PlatformInstanceFactory;
use Domains\Settings\Models\Language;
use App\Enums\PlatformEnum;
use Database\Factories\PlatformInstanceFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @method static updateOrCreate(array $array, $instanceData)
* @method static updateOrCreate(array<string, mixed> $array, $instanceData)
* @method static where(string $string, mixed $operator)
* @property PlatformEnum $platform
* @property string $url
@ -24,11 +23,6 @@ class PlatformInstance extends Model
/** @use HasFactory<PlatformInstanceFactory> */
use HasFactory;
protected static function newFactory(): PlatformInstanceFactory
{
return PlatformInstanceFactory::new();
}
protected $fillable = [
'platform',
'url',

66
app/Models/Route.php Normal file
View file

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Database\Factories\RouteFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $feed_id
* @property int $platform_channel_id
* @property bool $is_active
* @property int $priority
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Route extends Model
{
/** @use HasFactory<RouteFactory> */
use HasFactory;
protected $table = 'routes';
// Laravel doesn't handle composite primary keys well, so we'll use regular queries
protected $primaryKey = null;
public $incrementing = false;
protected $fillable = [
'feed_id',
'platform_channel_id',
'is_active',
'priority'
];
protected $casts = [
'is_active' => 'boolean'
];
/**
* @return BelongsTo<Feed, $this>
*/
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
/**
* @return BelongsTo<PlatformChannel, $this>
*/
public function platformChannel(): BelongsTo
{
return $this->belongsTo(PlatformChannel::class);
}
/**
* @return HasMany<Keyword, $this>
*/
public function keywords(): HasMany
{
return $this->hasMany(Keyword::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
}

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Settings\Models;
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -14,11 +14,6 @@ class Setting extends Model
{
use HasFactory;
protected static function newFactory()
{
return \Domains\Settings\Factories\SettingFactory::new();
}
protected $fillable = ['key', 'value'];
public static function get(string $key, mixed $default = null): mixed

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\User\Models;
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,14 +10,9 @@
class User extends Authenticatable
{
/** @use HasFactory<\Domains\User\Factories\UserFactory> */
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasApiTokens;
protected static function newFactory()
{
return \Domains\User\Factories\UserFactory::new();
}
/**
* The attributes that are mass assignable.
*

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Platform\Api\Lemmy;
namespace App\Modules\Lemmy;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response;

View file

@ -1,10 +1,10 @@
<?php
namespace Domains\Platform\Api\Lemmy;
namespace App\Modules\Lemmy\Services;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Platform\Models\PlatformChannelPost;
use Domains\Platform\Api\Lemmy\LemmyRequest;
use App\Enums\PlatformEnum;
use App\Models\PlatformChannelPost;
use App\Modules\Lemmy\LemmyRequest;
use Exception;
class LemmyApiService
@ -63,6 +63,11 @@ public function login(string $username, string $password): ?string
$data = $response->json();
return $data['jwt'] ?? null;
} 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]);
// If this was the first attempt and HTTPS, try HTTP next
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
@ -93,47 +98,6 @@ public function getCommunityId(string $communityName, string $token): int
}
}
/**
* Get full community details including language information
*
* @param string $communityName
* @param string $token
* @return array<string, mixed>
* @throws Exception
*/
public function getCommunityDetails(string $communityName, string $token): array
{
try {
$request = new LemmyRequest($this->instance, $token);
$response = $request->get('community', ['name' => $communityName]);
if (!$response->successful()) {
$statusCode = $response->status();
$responseBody = $response->body();
if ($statusCode === 404) {
throw new Exception("Community '{$communityName}' not found on this instance");
}
throw new Exception("Failed to fetch community details: {$statusCode} - {$responseBody}");
}
$data = $response->json();
if (!isset($data['community_view']['community'])) {
throw new Exception('Invalid community response format');
}
return $data['community_view'];
} catch (Exception $e) {
logger()->error('Community details lookup failed', [
'community_name' => $communityName,
'error' => $e->getMessage()
]);
throw $e;
}
}
public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void
{
try {
@ -246,70 +210,4 @@ public function getLanguages(): array
return [];
}
}
/**
* List communities on the instance with optional filtering
*
* @param string|null $token
* @param string $type Local, All, or Subscribed
* @param string $sort Hot, Active, New, TopDay, TopWeek, TopMonth, TopYear, TopAll
* @param int $limit Maximum number of communities to return (default: 50)
* @param int $page Page number for pagination (default: 1)
* @param bool $showNsfw Whether to include NSFW communities (default: false)
* @return array<string, mixed>
* @throws Exception
*/
public function listCommunities(
?string $token = null,
string $type = 'Local',
string $sort = 'Active',
int $limit = 50,
int $page = 1,
bool $showNsfw = false
): array {
try {
$request = new LemmyRequest($this->instance, $token);
$params = [
'type_' => $type,
'sort' => $sort,
'limit' => $limit,
'page' => $page,
'show_nsfw' => $showNsfw,
];
$response = $request->get('community/list', $params);
if (!$response->successful()) {
$statusCode = $response->status();
$responseBody = $response->body();
logger()->warning('Failed to fetch communities list', [
'status' => $statusCode,
'response' => $responseBody,
'params' => $params
]);
throw new Exception("Failed to fetch communities list: {$statusCode} - {$responseBody}");
}
$data = $response->json();
if (!isset($data['communities'])) {
logger()->warning('Invalid communities list response format', ['response' => $data]);
return ['communities' => []];
}
return $data;
} catch (Exception $e) {
logger()->error('Exception while fetching communities list', [
'error' => $e->getMessage(),
'type' => $type,
'sort' => $sort,
'limit' => $limit,
'page' => $page
]);
throw $e;
}
}
}

View file

@ -1,13 +1,12 @@
<?php
namespace Domains\Platform\Services\Publishing\Publishers;
namespace App\Modules\Lemmy\Services;
use Domains\Platform\Exceptions\PlatformAuthException;
use Domains\Article\Models\Article;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel;
use Domains\Platform\Services\Auth\Authenticators\LemmyAuthService;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use App\Exceptions\PlatformAuthException;
use App\Models\Article;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Services\Auth\LemmyAuthService;
use Exception;
class LemmyPublisher

View file

@ -2,9 +2,9 @@
namespace App\Providers;
use Domains\Logging\Enums\LogLevelEnum;
use Domains\Logging\Events\ExceptionOccurred;
use Domains\Logging\Listeners\LogExceptionToDatabase;
use App\Enums\LogLevelEnum;
use App\Events\ExceptionOccurred;
use App\Listeners\LogExceptionToDatabase;
use Error;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Event;
@ -26,8 +26,8 @@ public function boot(): void
);
Event::listen(
\Domains\Article\Events\NewArticleFetched::class,
\Domains\Article\Listeners\ValidateArticleListener::class,
\App\Events\NewArticleFetched::class,
\App\Listeners\ValidateArticleListener::class,
);

View file

@ -1,13 +1,13 @@
<?php
namespace Domains\Article\Services;
namespace App\Services\Article;
use Domains\Article\Models\Article;
use Domains\Feed\Models\Feed;
use Domains\Shared\Services\HttpFetcher;
use Domains\Article\Parsers\Factories\ArticleParserFactory;
use Domains\Article\Parsers\Factories\HomepageParserFactory;
use Domains\Logging\Services\LogSaver;
use App\Models\Article;
use App\Models\Feed;
use App\Services\Http\HttpFetcher;
use App\Services\Factories\ArticleParserFactory;
use App\Services\Factories\HomepageParserFactory;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Support\Collection;

View file

@ -1,8 +1,8 @@
<?php
namespace Domains\Article\Services;
namespace App\Services\Article;
use Domains\Article\Models\Article;
use App\Models\Article;
class ValidationService
{

View file

@ -1,11 +1,11 @@
<?php
namespace Domains\Platform\Services\Auth\Authenticators;
namespace App\Services\Auth;
use Domains\Platform\Enums\PlatformEnum;
use Domains\Platform\Exceptions\PlatformAuthException;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use App\Enums\PlatformEnum;
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Modules\Lemmy\Services\LemmyApiService;
use Exception;
class LemmyAuthService

View file

@ -1,13 +1,13 @@
<?php
namespace Domains\Article\Services;
namespace App\Services;
use Domains\Article\Models\Article;
use Domains\Article\Models\ArticlePublication;
use Domains\Feed\Models\Feed;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel;
use Domains\Feed\Models\Route;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\Route;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;

View file

@ -1,11 +1,11 @@
<?php
namespace Domains\Article\Parsers\Factories;
namespace App\Services\Factories;
use Domains\Article\Contracts\ArticleParserInterface;
use Domains\Feed\Models\Feed;
use Domains\Article\Parsers\Vrt\VrtArticleParser;
use Domains\Article\Parsers\Belga\BelgaArticleParser;
use App\Contracts\ArticleParserInterface;
use App\Models\Feed;
use App\Services\Parsers\VrtArticleParser;
use App\Services\Parsers\BelgaArticleParser;
use Exception;
class ArticleParserFactory

View file

@ -1,11 +1,11 @@
<?php
namespace Domains\Article\Parsers\Factories;
namespace App\Services\Factories;
use Domains\Article\Contracts\HomepageParserInterface;
use Domains\Feed\Models\Feed;
use Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter;
use Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter;
use App\Contracts\HomepageParserInterface;
use App\Models\Feed;
use App\Services\Parsers\VrtHomepageParserAdapter;
use App\Services\Parsers\BelgaHomepageParserAdapter;
use Exception;
class HomepageParserFactory

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Shared\Services;
namespace App\Services\Http;
use Illuminate\Support\Facades\Http;
use Exception;

View file

@ -1,10 +1,10 @@
<?php
namespace Domains\Logging\Services;
namespace App\Services\Log;
use Domains\Logging\Enums\LogLevelEnum;
use Domains\Logging\Models\Log;
use Domains\Platform\Models\PlatformChannel;
use App\Enums\LogLevelEnum;
use App\Models\Log;
use App\Models\PlatformChannel;
class LogSaver
{

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

View file

@ -1,6 +1,6 @@
<?php
namespace Domains\Article\Parsers\Belga;
namespace App\Services\Parsers;
class BelgaArticlePageParser
{

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