├── app ├── public │ ├── storage │ │ └── .gitkeep │ ├── 88x31.gif │ ├── assets │ │ ├── favicon.ico │ │ ├── ogimage.png │ │ ├── favicon-96x96.png │ │ ├── apple-touch-icon.png │ │ ├── web-app-manifest-192x192.png │ │ ├── web-app-manifest-512x512.png │ │ ├── css │ │ │ └── fonts │ │ │ │ ├── bootstrap-icons.woff │ │ │ │ └── bootstrap-icons.woff2 │ │ ├── site.webmanifest │ │ ├── lerama.svg │ │ └── favicon.svg │ ├── .htaccess │ └── index.php ├── setup │ ├── 2025-05-27.sql │ ├── initial.sql │ ├── 2025-10-29.sql │ ├── 2025-11-03.sql │ └── migration.php ├── package.json ├── .env.example ├── src │ ├── Middleware │ │ └── AuthMiddleware.php │ ├── Services │ │ ├── Translator.php │ │ ├── FeedTypeDetector.php │ │ ├── ProxyService.php │ │ ├── EmailService.php │ │ └── ThumbnailService.php │ ├── Config │ │ └── HttpClientConfig.php │ └── Controllers │ │ ├── HomeController.php │ │ ├── SuggestionController.php │ │ └── FeedController.php ├── composer.json ├── templates │ ├── tags-list.php │ ├── categories-list.php │ ├── admin │ │ ├── login.php │ │ ├── tag-form.php │ │ ├── category-form.php │ │ ├── tags.php │ │ ├── categories.php │ │ ├── items.php │ │ └── feed-form.php │ ├── feed-builder.php │ ├── layout.php │ └── suggest-feed.php ├── gulpfile.js ├── bin │ └── lerama └── lang │ ├── en.php │ ├── pt-BR.php │ └── es.php ├── .gitignore ├── startup ├── 20-migration ├── 05-storage └── 10-env ├── crontab ├── Dockerfile ├── docker-compose.yml ├── README.en.md ├── README.md └── .github └── workflows └── release.yml /app/public/storage/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/public/88x31.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/88x31.gif -------------------------------------------------------------------------------- /app/public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/favicon.ico -------------------------------------------------------------------------------- /app/public/assets/ogimage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/ogimage.png -------------------------------------------------------------------------------- /app/public/assets/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/favicon-96x96.png -------------------------------------------------------------------------------- /app/public/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /app/public/assets/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /app/public/assets/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /app/public/assets/css/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/css/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /app/public/assets/css/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/lerama/HEAD/app/public/assets/css/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/vendor/* 2 | vendor 3 | .env 4 | composer.lock 5 | **/node_modules/* 6 | app/public/storage/thumbnails/** 7 | app/storage/*.cache 8 | app/package-lock.json -------------------------------------------------------------------------------- /app/setup/2025-05-27.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `feeds` 2 | ADD COLUMN IF NOT EXISTS `retry_count` INT UNSIGNED DEFAULT 0, 3 | ADD COLUMN IF NOT EXISTS `retry_proxy` TINYINT(1) DEFAULT 0, 4 | ADD COLUMN IF NOT EXISTS `paused_at` DATETIME DEFAULT NULL; -------------------------------------------------------------------------------- /startup/20-migration: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Running database migrations..." 4 | php /app/setup/migration.php ENV_SOURCE=/app/.env 5 | 6 | if [ $? -eq 0 ]; then 7 | echo "Database migrations completed successfully" 8 | else 9 | echo "Database migrations failed" 10 | exit 1 11 | fi -------------------------------------------------------------------------------- /crontab: -------------------------------------------------------------------------------- 1 | # Process feeds 2 | 0 */4 * * * /usr/local/bin/php /app/bin/lerama feed:process 2>&1 | tee -a /tmp/feed_process.log 3 | 4 | # Check feed status 5 | 0 0 * * * /usr/local/bin/php /app/bin/lerama feed:check-status 2>&1 | tee -a /tmp/check_status.log 6 | 7 | # Update proxy list 8 | 0 0 * * * /usr/local/bin/php /app/bin/lerama proxy:update 2>&1 | tee -a /tmp/proxy_update.log -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lerama", 3 | "version": "1.0.0", 4 | "description": "Lerama", 5 | "scripts": { 6 | "build": "gulp", 7 | "watch": "gulp watch" 8 | }, 9 | "devDependencies": { 10 | "gulp": "^5.0.0", 11 | "gulp-concat": "^2.6.1", 12 | "gulp-clean-css": "^4.3.0", 13 | "gulp-rename": "^2.0.0" 14 | }, 15 | "dependencies": { 16 | "bootstrap": "^5.3.8", 17 | "bootstrap-icons": "^1.13.1" 18 | } 19 | } -------------------------------------------------------------------------------- /app/public/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lerama", 3 | "short_name": "Lerama", 4 | "icons": [ 5 | { 6 | "src": "/assets/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/assets/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#212529", 19 | "background_color": "#212529", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /startup/05-storage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Colors for output 5 | RED='\033[0;31m' 6 | GREEN='\033[0;32m' 7 | YELLOW='\033[1;33m' 8 | NC='\033[0m' 9 | 10 | log_success() { 11 | echo -e "${GREEN}[✓] $1${NC}" 12 | } 13 | 14 | log_info() { 15 | echo -e "${YELLOW}[i] $1${NC}" 16 | } 17 | 18 | log_info "Setting up storage directories..." 19 | 20 | # Create thumbnails directory 21 | mkdir -p /app/public/storage/thumbnails 22 | chown -R www-data:www-data /app/public/storage 23 | chmod -R 755 /app/public/storage 24 | 25 | log_success "Storage directories configured successfully" -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Lerama 2 | APP_URL=https://lerama.lab 3 | APP_LANGUAGE=pt-BR 4 | APP_NOTIFY_REGISTRATION=email@email.com 5 | 6 | LERAMA_DB_HOST=localhost 7 | LERAMA_DB_NAME=lerama 8 | LERAMA_DB_USER=root 9 | LERAMA_DB_PASS=root 10 | LERAMA_DB_PORT=3306 11 | 12 | ADMIN_USERNAME=admin 13 | ADMIN_PASSWORD=admin 14 | ADMIN_EMAIL= 15 | 16 | PROXY_LIST= 17 | 18 | FEED_WORKERS=1 19 | SUBSCRIBER_SHOW_POST=false 20 | 21 | SMTP_HOST=smtp.resend.com 22 | SMTP_PORT=587 23 | SMTP_USERNAME=resend 24 | SMTP_PASSWORD=re_ 25 | SMTP_SECURE=tls 26 | SMTP_FROM_EMAIL=lerama@lerama.lab 27 | SMTP_FROM_NAME=Lerama -------------------------------------------------------------------------------- /app/public/.htaccess: -------------------------------------------------------------------------------- 1 | # Enable rewrite engine 2 | RewriteEngine On 3 | 4 | # If the requested file or directory exists, serve it directly 5 | RewriteCond %{REQUEST_FILENAME} -f [OR] 6 | RewriteCond %{REQUEST_FILENAME} -d 7 | RewriteRule ^ - [L] 8 | 9 | # If the request is for a file in the storage directory that doesn't exist, 10 | # redirect to the actual storage directory 11 | RewriteCond %{REQUEST_URI} ^/storage/(.*)$ 12 | RewriteCond %{DOCUMENT_ROOT}/../storage/%1 -f 13 | RewriteRule ^storage/(.*)$ ../storage/$1 [L] 14 | 15 | # Otherwise, route all requests to index.php 16 | RewriteRule ^ index.php [L] -------------------------------------------------------------------------------- /app/public/assets/lerama.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/src/Middleware/AuthMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/public/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lerama/feed-aggregator", 3 | "description": "A feed aggregator application supporting multiple feed formats", 4 | "type": "project", 5 | "require": { 6 | "php": "^8.3", 7 | "vlucas/phpdotenv": "^5.5", 8 | "league/climate": "^3.8", 9 | "league/route": "^5.1", 10 | "simplepie/simplepie": "^1.8", 11 | "sergeytsalkov/meekrodb": "^2.4", 12 | "laminas/laminas-diactoros": "^2.24", 13 | "laminas/laminas-httphandlerrunner": "^2.5", 14 | "league/plates": "^3.5", 15 | "guzzlehttp/guzzle": "^7.9", 16 | "phpmailer/phpmailer": "^6.8", 17 | "gregwar/captcha": "1.3.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Lerama\\": "src/" 22 | } 23 | }, 24 | "scripts": { 25 | "post-create-project-cmd": [ 26 | "php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM shinsenter/php:8.3-fpm-nginx 2 | 3 | # Default application envs 4 | ENV ENABLE_CRONTAB=1 5 | ENV APP_PATH=/app 6 | ENV DOCUMENT_ROOT=public 7 | ENV TZ=UTC 8 | ENV ENABLE_TUNING_FPM=1 9 | ENV DISABLE_AUTORUN_SCRIPTS=0 10 | 11 | # Copy application files 12 | COPY app/ ${APP_PATH}/ 13 | WORKDIR ${APP_PATH} 14 | 15 | # Install composer dependencies 16 | RUN composer config platform.php-64bit 8.3 && \ 17 | composer install --no-interaction --optimize-autoloader --no-dev 18 | 19 | # Copy cron jobs configuration 20 | COPY crontab /etc/crontab.d/lerama 21 | RUN chmod 0644 /etc/crontab.d/lerama 22 | 23 | # Copy startup scripts 24 | COPY /startup/05-storage /startup/05-storage 25 | RUN chmod +x /startup/05-storage 26 | 27 | COPY /startup/10-env /startup/10-env 28 | RUN chmod +x /startup/10-env 29 | 30 | COPY /startup/20-migration /startup/20-migration 31 | RUN chmod +x /startup/20-migration 32 | 33 | # Set permissions 34 | RUN chown -R www-data:www-data ${APP_PATH} && \ 35 | chmod -R 755 ${APP_PATH} && \ 36 | mkdir -p ${APP_PATH}/${DOCUMENT_ROOT}/storage/thumbnails && \ 37 | chown -R www-data:www-data ${APP_PATH}/${DOCUMENT_ROOT}/storage 38 | 39 | EXPOSE 80 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | lerama: 3 | image: ghcr.io/manualdousuario/lerama:2 4 | container_name: lerama 5 | environment: 6 | TZ: ${TZ:-UTC} 7 | APP_URL: https://lerama.lab 8 | LERAMA_DB_HOST: localhost 9 | LERAMA_DB_PORT: 3306 10 | LERAMA_DB_NAME: lerama 11 | LERAMA_DB_USER: root 12 | LERAMA_DB_PASS: root 13 | ADMIN_USERNAME: admin 14 | ADMIN_PASSWORD: admin 15 | ADMIN_EMAIL: 16 | PROXY_LIST: # optional 17 | SMTP_HOST: smtp.resend.com 18 | SMTP_PORT: 587 19 | SMTP_USERNAME: resend 20 | SMTP_PASSWORD: re_ 21 | SMTP_SECURE: tls 22 | SMTP_FROM_EMAIL: lerama@lerama.lab 23 | SMTP_FROM_NAME: Lerama 24 | ports: 25 | - 80:80 26 | volumes: 27 | - ./lerama/storage:/app/public/storage 28 | restart: unless-stopped 29 | networks: 30 | - lerama 31 | depends_on: 32 | - db 33 | db: 34 | image: mariadb:10.11 35 | container_name: db 36 | environment: 37 | MYSQL_ROOT_PASSWORD: SENHA_ROOT 38 | MYSQL_DATABASE: BANCO_DE_DADOS 39 | MYSQL_USER: USUARIO 40 | MYSQL_PASSWORD: SENHA 41 | ports: 42 | - 3306:3306 43 | volumes: 44 | - ./mariadb/data:/var/lib/mysql 45 | networks: 46 | - lerama 47 | networks: 48 | lerama: 49 | driver: bridge -------------------------------------------------------------------------------- /startup/10-env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Colors for output 5 | RED='\033[0;31m' 6 | GREEN='\033[0;32m' 7 | YELLOW='\033[1;33m' 8 | NC='\033[0m' 9 | 10 | log_success() { 11 | echo -e "${GREEN}[✓] $1${NC}" 12 | } 13 | 14 | log_info() { 15 | echo -e "${YELLOW}[i] $1${NC}" 16 | } 17 | 18 | # Create .env file with environment variables 19 | log_info "Setting up environment variables in /app/.env..." 20 | cat > /app/.env << EOL 21 | APP_NAME=Lerama 22 | APP_URL=${APP_URL:-https://lerama.lab} 23 | LERAMA_DB_HOST=${LERAMA_DB_HOST:-localhost} 24 | LERAMA_DB_PORT=${LERAMA_DB_PORT:-3306} 25 | LERAMA_DB_NAME=${LERAMA_DB_NAME:-lerama} 26 | LERAMA_DB_USER=${LERAMA_DB_USER:-root} 27 | LERAMA_DB_PASS=${LERAMA_DB_PASS:-root} 28 | ADMIN_USERNAME=${ADMIN_USERNAME:-admin} 29 | ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} 30 | PROXY_LIST=${PROXY_LIST:-} 31 | ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} 32 | SMTP_HOST=${SMTP_HOST:-} 33 | SMTP_PORT=${SMTP_PORT:-587} 34 | SMTP_USERNAME=${SMTP_USERNAME:-} 35 | SMTP_PASSWORD=${SMTP_PASSWORD:-} 36 | SMTP_SECURE=${SMTP_SECURE:-tls} 37 | SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-lerama@example.com} 38 | SMTP_FROM_NAME=${SMTP_FROM_NAME:-"Lerama"} 39 | EOL 40 | 41 | log_success "Environment variables set in /app/.env" 42 | 43 | # Update proxy list if PROXY_LIST environment variable is defined 44 | if [ -n "$PROXY_LIST" ]; then 45 | log_info "PROXY_LIST environment variable detected, will update proxy list after startup..." 46 | fi -------------------------------------------------------------------------------- /app/templates/tags-list.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => 'Tópicos']) ?> 2 | 3 | start('active') ?>tagsstop() ?> 4 | 5 |
6 |
7 |

8 | 9 | 10 |

11 |
12 | 13 | 14 |
15 |

16 |
17 | 18 |
19 | 20 | 21 |
22 |
23 | 24 | e($tag['name']) ?> 25 |
26 | 27 |

e($tag['description']) ?>

28 | 29 |
30 | 31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 |
-------------------------------------------------------------------------------- /app/templates/categories-list.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => 'Categorias']) ?> 2 | 3 | start('active') ?>categoriesstop() ?> 4 | 5 |
6 |
7 |

8 | 9 | 10 |

11 |
12 | 13 | 14 |
15 |

16 |
17 | 18 |
19 | 20 | 21 |
22 |
23 | 24 | e($category['name']) ?> 25 |
26 | 27 |

e($category['description']) ?>

28 | 29 |
30 | 31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 |
-------------------------------------------------------------------------------- /app/gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const concat = require('gulp-concat'); 3 | const cleanCSS = require('gulp-clean-css'); 4 | const rename = require('gulp-rename'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | const paths = { 9 | bootstrap: { 10 | css: 'node_modules/bootstrap/dist/css/bootstrap.min.css', 11 | cssMap: 'node_modules/bootstrap/dist/css/bootstrap.min.css.map' 12 | }, 13 | bootstrapIcons: { 14 | css: 'node_modules/bootstrap-icons/font/bootstrap-icons.min.css', 15 | fonts: 'node_modules/bootstrap-icons/font/fonts/*' 16 | }, 17 | dest: { 18 | css: 'public/assets/css', 19 | fonts: 'public/assets/css/fonts' 20 | } 21 | }; 22 | 23 | function bootstrapCSS() { 24 | return gulp.src([paths.bootstrap.css, paths.bootstrap.cssMap]) 25 | .pipe(gulp.dest(paths.dest.css)); 26 | } 27 | 28 | function bootstrapIconsCSS() { 29 | return gulp.src(paths.bootstrapIcons.css) 30 | .pipe(gulp.dest(paths.dest.css)); 31 | } 32 | 33 | function bootstrapIconsFonts() { 34 | return gulp.src(paths.bootstrapIcons.fonts, { encoding: false }) 35 | .pipe(gulp.dest(paths.dest.fonts)); 36 | } 37 | 38 | function combinedCSS() { 39 | return gulp.src([ 40 | paths.bootstrap.css, 41 | paths.bootstrapIcons.css 42 | ]) 43 | .pipe(concat('lerama.css')) 44 | .pipe(gulp.dest(paths.dest.css)) 45 | .pipe(cleanCSS()) 46 | .pipe(rename({ suffix: '.min' })) 47 | .pipe(gulp.dest(paths.dest.css)); 48 | } 49 | 50 | function watchFiles() { 51 | gulp.watch('node_modules/bootstrap/dist/css/**/*', bootstrapCSS); 52 | gulp.watch('node_modules/bootstrap-icons/font/**/*', gulp.parallel(bootstrapIconsCSS, bootstrapIconsFonts)); 53 | } 54 | 55 | exports.bootstrap = bootstrapCSS; 56 | exports.icons = gulp.parallel(bootstrapIconsCSS, bootstrapIconsFonts); 57 | exports.combined = combinedCSS; 58 | exports.watch = watchFiles; 59 | 60 | exports.default = gulp.series( 61 | gulp.parallel(bootstrapIconsFonts), 62 | combinedCSS 63 | ); -------------------------------------------------------------------------------- /app/templates/admin/login.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>loginstop() ?> 4 | 5 |
6 |
7 |
8 |
9 |

10 | 11 |

12 |
13 | 14 | 15 | 21 | 22 | 23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 |
34 | 35 | 36 | 37 | 38 |
39 |
40 | 41 |
42 | 46 |
47 |
48 |
49 |
50 |
-------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # 📰 Lerama 2 | 3 | [![PHP 8.3+](https://img.shields.io/badge/PHP-8.3%2B-purple.svg)](https://www.php.net/) 4 | [![Docker](https://img.shields.io/badge/Docker-ready-blue.svg)](https://www.docker.com/) 5 | [![GPL v3](https://img.shields.io/badge/license-GPL%20v3-blue.svg)](LICENSE.md) 6 | [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](https://github.com/manualdousuario/lerama/blob/master/README.md) 7 | 8 | Lightweight and efficient feed aggregator, developed as an alternative to [OpenOrb](https://git.sr.ht/~lown/openorb) for [PC do Manual](https://pcdomanual.com/). 9 | 10 | 🌐 **Public instance**: [lerama.pcdomanual.com](https://lerama.pcdomanual.com/) 11 | 12 | --- 13 | 14 | ## ✨ Features 15 | 16 | - RSS 1.0, RSS 2.0, ATOM, RDF, JSON Feed 17 | - CSV import 18 | - Filter by individual feed, categories and topics/tags 19 | - Text search in titles and content 20 | - Cron scheduling 21 | - Batch processing 22 | - Incremental updates 23 | - Proxy support for blocked feeds 24 | - Automatic thumbnail download 25 | - Image caching 26 | - Feed, category and tag management 27 | - Community suggestions 28 | - Multi-language: Portuguese (pt-BR), English (en), Spanish (es) 29 | 30 | --- 31 | 32 | ## 🚀 Installation 33 | 34 | 1. **Download the configuration file:** 35 | ```bash 36 | curl -o docker-compose.yml https://raw.githubusercontent.com/manualdousuario/lerama/main/docker-compose.yml 37 | ``` 38 | 39 | 2. **Configure environment variables:** 40 | ```bash 41 | nano docker-compose.yml 42 | ``` 43 | 44 | **Required variables:** 45 | ```yaml 46 | ADMIN_USERNAME: your_username # Admin user 47 | ADMIN_PASSWORD: strong_password # Admin password (min. 8 characters) 48 | APP_URL: https://your-domain.com 49 | 50 | # Database 51 | LERAMA_DB_HOST: db 52 | LERAMA_DB_NAME: lerama 53 | LERAMA_DB_USER: root 54 | LERAMA_DB_PASS: secure_password 55 | ``` 56 | 57 | 3. **Start the containers:** 58 | ```bash 59 | docker-compose up -d 60 | ``` 61 | 62 | 4. **Access the system:** 63 | - Frontend: `http://localhost:80` 64 | - Admin: `http://localhost:80/admin` 65 | 66 | --- 67 | 68 | ## 💬 Support 69 | 70 | - 🐛 Found a bug? [Open an issue](https://github.com/manualdousuario/lerama/issues) 71 | - 💡 Have a suggestion? [Open an issue](https://github.com/manualdousuario/lerama/issues) 72 | 73 | --- 74 | 75 | Made with ❤️ for [PC do Manual](https://pcdomanual.com/) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📰 Lerama 2 | 3 | [![PHP 8.3+](https://img.shields.io/badge/PHP-8.3%2B-purple.svg)](https://www.php.net/) 4 | [![Docker](https://img.shields.io/badge/Docker-ready-blue.svg)](https://www.docker.com/) 5 | [![GPL v3](https://img.shields.io/badge/license-GPL%20v3-blue.svg)](LICENSE.md) 6 | [![en](https://img.shields.io/badge/lang-en-red.svg)](https://github.com/manualdousuario/lerama/blob/master/README.en.md) 7 | 8 | Agregador de feeds leve e eficiente, desenvolvido como alternativa ao [OpenOrb](https://git.sr.ht/~lown/openorb) para o [PC do Manual](https://pcdomanual.com/). 9 | 10 | 🌐 **Instância pública**: [lerama.pcdomanual.com](https://lerama.pcdomanual.com/) 11 | 12 | --- 13 | 14 | ## ✨ Recursos 15 | 16 | - RSS 1.0, RSS 2.0, ATOM, RDF, JSON Feed 17 | - Importação via CSV 18 | - Filtro por feed individual, categorias e tópicos/tags 19 | - Busca textual em títulos e conteúdo 20 | - Agendamento via cron 21 | - Processamento em lote 22 | - Atualização incremental 23 | - Suporte a proxy para feeds bloqueados 24 | - Download automático de thumbnails 25 | - Cache de imagens 26 | - Gerenciamento de feeds, categorias e tags 27 | - Sugestões da comunidade 28 | - Multi-idioma: Português (pt-BR), Inglês (en), Espanhol (es) 29 | 30 | --- 31 | 32 | ## 🚀 Instalação 33 | 34 | 1. **Baixe o arquivo de configuração:** 35 | ```bash 36 | curl -o docker-compose.yml https://raw.githubusercontent.com/manualdousuario/lerama/main/docker-compose.yml 37 | ``` 38 | 39 | 2. **Configure as variáveis de ambiente:** 40 | ```bash 41 | nano docker-compose.yml 42 | ``` 43 | 44 | **Variáveis obrigatórias:** 45 | ```yaml 46 | ADMIN_USERNAME: seu_usuario # Usuário admin 47 | ADMIN_PASSWORD: senha_forte # Senha do admin (min. 8 caracteres) 48 | APP_URL: https://seu-dominio.com 49 | 50 | # Banco de dados 51 | LERAMA_DB_HOST: db 52 | LERAMA_DB_NAME: lerama 53 | LERAMA_DB_USER: root 54 | LERAMA_DB_PASS: senha_segura 55 | ``` 56 | 57 | 3. **Inicie os containers:** 58 | ```bash 59 | docker-compose up -d 60 | ``` 61 | 62 | 4. **Acesse o sistema:** 63 | - Frontend: `http://localhost:80` 64 | - Admin: `http://localhost:80/admin` 65 | 66 | --- 67 | 68 | ## 💬 Suporte 69 | 70 | - 🐛 Encontrou um bug? [Abra uma issue](https://github.com/manualdousuario/lerama/issues) 71 | - 💡 Tem uma sugestão? [Abra uma issue](https://github.com/manualdousuario/lerama/issues) 72 | 73 | --- 74 | 75 | Feito com ❤️ para o [PC do Manual](https://pcdomanual.com/) -------------------------------------------------------------------------------- /app/src/Services/Translator.php: -------------------------------------------------------------------------------- 1 | language = $_ENV['APP_LANGUAGE'] ?? 'pt-BR'; 14 | $this->loadTranslations($this->language); 15 | 16 | // Load fallback if different from current 17 | if ($this->language !== $this->fallbackLanguage) { 18 | $this->loadTranslations($this->fallbackLanguage, true); 19 | } 20 | } 21 | 22 | public static function getInstance(): self 23 | { 24 | if (self::$instance === null) { 25 | self::$instance = new self(); 26 | } 27 | return self::$instance; 28 | } 29 | 30 | private function loadTranslations(string $language, bool $isFallback = false): void 31 | { 32 | $langFile = __DIR__ . '/../../lang/' . $language . '.php'; 33 | 34 | if (file_exists($langFile)) { 35 | $translations = require $langFile; 36 | 37 | if ($isFallback) { 38 | // Merge with existing, keeping the main language values 39 | $this->translations = array_merge($translations, $this->translations); 40 | } else { 41 | $this->translations = $translations; 42 | } 43 | } 44 | } 45 | 46 | public function translate(string $key, array $replacements = []): string 47 | { 48 | $translation = $this->translations[$key] ?? $key; 49 | 50 | // Replace placeholders like :name, :count, etc. 51 | foreach ($replacements as $placeholder => $value) { 52 | $translation = str_replace(':' . $placeholder, $value, $translation); 53 | } 54 | 55 | return $translation; 56 | } 57 | 58 | public function getLanguage(): string 59 | { 60 | return $this->language; 61 | } 62 | 63 | public function getAvailableLanguages(): array 64 | { 65 | return [ 66 | 'pt-BR' => 'Português (Brasil)', 67 | 'en' => 'English', 68 | 'es' => 'Español' 69 | ]; 70 | } 71 | } 72 | } 73 | 74 | namespace { 75 | // Helper functions in global namespace 76 | if (!function_exists('__')) { 77 | function __(string $key, array $replacements = []): string 78 | { 79 | return \App\Services\Translator::getInstance()->translate($key, $replacements); 80 | } 81 | } 82 | 83 | if (!function_exists('current_language')) { 84 | function current_language(): string 85 | { 86 | return \App\Services\Translator::getInstance()->getLanguage(); 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /app/templates/admin/tag-form.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>admin-tagsstop() ?> 4 | 5 |
6 |
7 |

8 | 9 | e($title) ?> 10 |

11 |
12 | 13 |
14 |
15 | 16 | 20 | 21 | 22 |
23 | 26 |
27 | 28 | 29 | 30 | 38 |
39 | 40 |
41 | e($errors['name']) ?> 42 |
43 | 44 |
45 | 46 |
47 | 50 |
51 | 52 | 53 | 54 | 61 |
62 |
63 | 64 |
65 | e($errors['slug']) ?> 66 |
67 | 68 |
69 | 70 |
71 | 72 | 73 | 74 | 75 | 79 |
80 |
81 |
82 |
-------------------------------------------------------------------------------- /app/templates/admin/category-form.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>admin-categoriesstop() ?> 4 | 5 |
6 |
7 |

8 | 9 | e($title) ?> 10 |

11 |
12 | 13 |
14 |
15 | 16 | 20 | 21 | 22 |
23 | 26 |
27 | 28 | 29 | 30 | 38 |
39 | 40 |
41 | e($errors['name']) ?> 42 |
43 | 44 |
45 | 46 |
47 | 50 |
51 | 52 | 53 | 54 | 61 |
62 |
63 | 64 |
65 | e($errors['slug']) ?> 66 |
67 | 68 |
69 | 70 |
71 | 72 | 73 | 74 | 75 | 79 |
80 |
81 |
82 |
-------------------------------------------------------------------------------- /app/setup/initial.sql: -------------------------------------------------------------------------------- 1 | SET NAMES utf8mb4; 2 | SET FOREIGN_KEY_CHECKS = 0; 3 | 4 | DROP TABLE IF EXISTS `feeds`; 5 | CREATE TABLE `feeds` ( 6 | `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, 7 | `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 8 | `feed_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 9 | `site_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 10 | `feed_type` enum('rss1','rss2','atom','rdf','csv','json','xml') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 11 | `last_post_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, 12 | `last_checked` datetime NULL DEFAULT NULL, 13 | `last_updated` datetime NULL DEFAULT NULL, 14 | `status` enum('online','offline','paused','pending','rejected') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'online', 15 | `created_at` datetime NULL DEFAULT current_timestamp(), 16 | `updated_at` datetime NULL DEFAULT current_timestamp() ON UPDATE CURRENT_TIMESTAMP, 17 | `language` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, 18 | PRIMARY KEY (`id`) USING BTREE, 19 | UNIQUE INDEX `feed_url`(`feed_url`(255)) USING BTREE, 20 | INDEX `idx_feed_status`(`status`) USING BTREE, 21 | INDEX `idx_feed_type`(`feed_type`) USING BTREE, 22 | INDEX `idx_last_checked`(`last_checked`) USING BTREE, 23 | INDEX `idx_last_updated`(`last_updated`) USING BTREE, 24 | INDEX `idx_status_checked`(`status`, `last_checked`) USING BTREE, 25 | INDEX `idx_language`(`language`) USING BTREE, 26 | INDEX `idx_title`(`title`(100)) USING BTREE 27 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 28 | 29 | DROP TABLE IF EXISTS `feed_items`; 30 | CREATE TABLE `feed_items` ( 31 | `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, 32 | `feed_id` int(10) UNSIGNED NOT NULL, 33 | `title` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 34 | `author` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, 35 | `content` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, 36 | `url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 37 | `image_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, 38 | `guid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 39 | `published_at` datetime NULL DEFAULT NULL, 40 | `is_visible` tinyint(1) NULL DEFAULT 1, 41 | `created_at` datetime NULL DEFAULT current_timestamp(), 42 | `updated_at` datetime NULL DEFAULT current_timestamp() ON UPDATE CURRENT_TIMESTAMP, 43 | PRIMARY KEY (`id`) USING BTREE, 44 | UNIQUE INDEX `unique_item`(`feed_id`, `guid`) USING BTREE, 45 | FULLTEXT INDEX `idx_title_content`(`title`, `content`), 46 | INDEX `idx_published_at`(`published_at`) USING BTREE, 47 | INDEX `idx_is_visible`(`is_visible`) USING BTREE, 48 | INDEX `idx_feed_published`(`feed_id`, `published_at`) USING BTREE, 49 | INDEX `idx_feed_visible`(`feed_id`, `is_visible`) USING BTREE, 50 | INDEX `idx_visible_published`(`is_visible`, `published_at`) USING BTREE, 51 | INDEX `idx_feed_visible_published`(`feed_id`, `is_visible`, `published_at`) USING BTREE, 52 | CONSTRAINT `feed_items_ibfk_1` FOREIGN KEY (`feed_id`) REFERENCES `feeds` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT 53 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 54 | 55 | SET FOREIGN_KEY_CHECKS = 1; -------------------------------------------------------------------------------- /app/setup/2025-10-29.sql: -------------------------------------------------------------------------------- 1 | -- Migration for Tags, Categories, and Feed Suggestions 2 | -- Date: 2025-10-29 3 | 4 | SET NAMES utf8mb4; 5 | SET FOREIGN_KEY_CHECKS = 0; 6 | 7 | -- Create migrations tracking table 8 | CREATE TABLE IF NOT EXISTS `migrations` ( 9 | `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, 10 | `migration` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 11 | `executed_at` datetime NULL DEFAULT current_timestamp(), 12 | PRIMARY KEY (`id`) USING BTREE, 13 | UNIQUE INDEX `migration`(`migration`) USING BTREE, 14 | INDEX `idx_executed_at`(`executed_at`) USING BTREE 15 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 16 | 17 | -- Create categories table 18 | CREATE TABLE IF NOT EXISTS `categories` ( 19 | `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, 20 | `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 21 | `slug` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 22 | `created_at` datetime NULL DEFAULT current_timestamp(), 23 | `updated_at` datetime NULL DEFAULT current_timestamp() ON UPDATE CURRENT_TIMESTAMP, 24 | PRIMARY KEY (`id`) USING BTREE, 25 | UNIQUE INDEX `slug`(`slug`) USING BTREE 26 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 27 | 28 | -- Create tags table 29 | CREATE TABLE IF NOT EXISTS `tags` ( 30 | `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, 31 | `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 32 | `slug` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 33 | `created_at` datetime NULL DEFAULT current_timestamp(), 34 | `updated_at` datetime NULL DEFAULT current_timestamp() ON UPDATE CURRENT_TIMESTAMP, 35 | PRIMARY KEY (`id`) USING BTREE, 36 | UNIQUE INDEX `slug`(`slug`) USING BTREE 37 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 38 | 39 | -- Create feed_categories junction table 40 | CREATE TABLE IF NOT EXISTS `feed_categories` ( 41 | `feed_id` int(10) UNSIGNED NOT NULL, 42 | `category_id` int(10) UNSIGNED NOT NULL, 43 | `created_at` datetime NULL DEFAULT current_timestamp(), 44 | PRIMARY KEY (`feed_id`, `category_id`) USING BTREE, 45 | INDEX `idx_category_id`(`category_id`) USING BTREE, 46 | CONSTRAINT `feed_categories_ibfk_1` FOREIGN KEY (`feed_id`) REFERENCES `feeds` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, 47 | CONSTRAINT `feed_categories_ibfk_2` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT 48 | ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 49 | 50 | -- Create feed_tags junction table 51 | CREATE TABLE IF NOT EXISTS `feed_tags` ( 52 | `feed_id` int(10) UNSIGNED NOT NULL, 53 | `tag_id` int(10) UNSIGNED NOT NULL, 54 | `created_at` datetime NULL DEFAULT current_timestamp(), 55 | PRIMARY KEY (`feed_id`, `tag_id`) USING BTREE, 56 | INDEX `idx_tag_id`(`tag_id`) USING BTREE, 57 | CONSTRAINT `feed_tags_ibfk_1` FOREIGN KEY (`feed_id`) REFERENCES `feeds` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, 58 | CONSTRAINT `feed_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT 59 | ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic; 60 | 61 | -- Modify status enum to include 'pending' and 'rejected' for feed suggestions 62 | ALTER TABLE `feeds` 63 | MODIFY COLUMN `status` enum('online','offline','paused','pending','rejected') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'online', 64 | ADD COLUMN IF NOT EXISTS `submitter_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL AFTER `language`; 65 | 66 | -- Add composite index on feed_categories for better JOIN performance 67 | ALTER TABLE `feed_categories` 68 | ADD INDEX IF NOT EXISTS `idx_feed_category_lookup`(`category_id`, `feed_id`) USING BTREE; 69 | 70 | -- Add composite index on feed_tags for better JOIN performance 71 | ALTER TABLE `feed_tags` 72 | ADD INDEX IF NOT EXISTS `idx_feed_tag_lookup`(`tag_id`, `feed_id`) USING BTREE; 73 | 74 | -- Add index on categories slug for filtering 75 | ALTER TABLE `categories` 76 | ADD INDEX IF NOT EXISTS `idx_slug`(`slug`) USING BTREE; 77 | 78 | -- Add index on tags slug for filtering 79 | ALTER TABLE `tags` 80 | ADD INDEX IF NOT EXISTS `idx_slug`(`slug`) USING BTREE; 81 | 82 | SET FOREIGN_KEY_CHECKS = 1; -------------------------------------------------------------------------------- /app/src/Services/FeedTypeDetector.php: -------------------------------------------------------------------------------- 1 | httpClient = new \GuzzleHttp\Client(HttpClientConfig::getDefaultConfig()); 20 | } 21 | 22 | public function detectType(string $url, ?int $feedId = null): ?string 23 | { 24 | try { 25 | $response = $this->httpClient->get($url); 26 | $statusCode = $response->getStatusCode(); 27 | 28 | if ($statusCode !== 200) { 29 | if ($feedId) { 30 | $this->pauseFeedWithError($feedId, "HTTP error: Status code {$statusCode}"); 31 | } 32 | return null; 33 | } 34 | 35 | $content = (string) $response->getBody(); 36 | if (empty($content)) { 37 | if ($feedId) { 38 | $this->pauseFeedWithError($feedId, "Empty response received"); 39 | } 40 | return null; 41 | } 42 | 43 | return $this->detectTypeFromContent($content); 44 | } catch (\Exception $e) { 45 | if ($feedId) { 46 | $this->pauseFeedWithError($feedId, $e->getMessage()); 47 | } 48 | return null; 49 | } 50 | } 51 | 52 | private function pauseFeedWithError(int $feedId, string $errorMessage): void 53 | { 54 | try { 55 | DB::update('feeds', [ 56 | 'status' => 'paused', 57 | 'last_error' => $errorMessage, 58 | 'last_checked' => DB::sqleval("NOW()") 59 | ], 'id=%i', $feedId); 60 | } catch (\Exception $e) { 61 | // Log error if needed 62 | } 63 | } 64 | 65 | 66 | public function detectTypeFromContent(string $content): ?string 67 | { 68 | if ($this->isJson($content)) { 69 | return 'json'; 70 | } 71 | 72 | if ($this->isCsv($content)) { 73 | return 'csv'; 74 | } 75 | 76 | if ($this->isXml($content)) { 77 | return $this->detectXmlFeedType($content); 78 | } 79 | 80 | return null; 81 | } 82 | 83 | private function isJson(string $content): bool 84 | { 85 | json_decode($content); 86 | return json_last_error() === JSON_ERROR_NONE; 87 | } 88 | 89 | private function isCsv(string $content): bool 90 | { 91 | $lines = explode("\n", $content); 92 | if (count($lines) < 2) { 93 | return false; 94 | } 95 | 96 | $firstLine = trim($lines[0]); 97 | if (strpos($firstLine, ',') === false) { 98 | return false; 99 | } 100 | 101 | $secondLine = trim($lines[1]); 102 | if (strpos($secondLine, ',') === false) { 103 | return false; 104 | } 105 | 106 | $firstLineCommas = substr_count($firstLine, ','); 107 | $secondLineCommas = substr_count($secondLine, ','); 108 | 109 | return abs($firstLineCommas - $secondLineCommas) <= 1; 110 | } 111 | 112 | private function isXml(string $content): bool 113 | { 114 | $content = trim($content); 115 | return ( 116 | strpos($content, 'channel) && isset($xml->channel->item)) { 132 | return 'rss2'; 133 | } 134 | 135 | if (isset($xml->entry) || $xml->getName() === 'feed') { 136 | return 'atom'; 137 | } 138 | 139 | if (strpos($content, 'item) && !isset($xml->channel)) { 144 | return 'rss1'; 145 | } 146 | 147 | return 'xml'; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /app/templates/admin/tags.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>admin-tagsstop() ?> 4 | 5 |
6 |
7 |
8 |

9 | 10 | 11 |

12 |
13 | 19 |
20 | 21 | 22 |
23 |

24 | 25 | 26 |

27 |
28 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 46 | 49 | 54 | 64 | 65 | 66 | 67 |
44 | e($tag['name']) ?> 45 | 47 | e($tag['slug']) ?> 48 | 50 | 51 | 52 | 53 | 55 |
56 | 57 | 58 | 59 | 62 |
63 |
68 |
69 |
70 | 71 |
72 | 73 | start('scripts') ?> 74 | 100 | stop() ?> -------------------------------------------------------------------------------- /app/src/Config/HttpClientConfig.php: -------------------------------------------------------------------------------- 1 | 30, 19 | 'connect_timeout' => 15, 20 | 'http_errors' => false, 21 | 'allow_redirects' => [ 22 | 'max' => 4, 23 | 'strict' => false, 24 | 'referer' => true, 25 | 'protocols' => ['http', 'https'], 26 | 'track_redirects' => false 27 | ], 28 | 29 | 'headers' => [ 30 | 'User-Agent' => self::getRandomUserAgent(), 31 | 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 32 | 'Accept-Language' => 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 33 | 'Accept-Encoding' => 'gzip, deflate, br', 34 | 'Connection' => 'keep-alive', 35 | 'Upgrade-Insecure-Requests' => '1', 36 | 'Sec-Fetch-Dest' => 'document', 37 | 'Sec-Fetch-Mode' => 'navigate', 38 | 'Sec-Fetch-Site' => 'none', 39 | ], 40 | 'curl' => [ 41 | CURLOPT_ENCODING => '', 42 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, 43 | CURLOPT_SSL_VERIFYPEER => true, 44 | CURLOPT_SSL_VERIFYHOST => 2, 45 | CURLOPT_FOLLOWLOCATION => true, 46 | CURLOPT_AUTOREFERER => true, 47 | CURLOPT_COOKIEFILE => '', 48 | CURLOPT_COOKIEJAR => '', 49 | ], 50 | 'verify' => true, 51 | 'decode_content' => 'gzip' 52 | ]; 53 | } 54 | 55 | /** 56 | * Get a random modern User-Agent to rotate and avoid detection 57 | * 58 | * @return string 59 | */ 60 | private static function getRandomUserAgent(): string 61 | { 62 | $userAgents = [ 63 | // Chrome on Windows 64 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 65 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', 66 | 67 | // Chrome on macOS 68 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 69 | 70 | // Firefox on Windows 71 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', 72 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0', 73 | 74 | // Firefox on macOS 75 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0', 76 | 77 | // Safari on macOS 78 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', 79 | 80 | // Edge on Windows 81 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', 82 | ]; 83 | 84 | return $userAgents[array_rand($userAgents)]; 85 | } 86 | 87 | /** 88 | * Get simplified HTTP client configuration for image extraction 89 | * 90 | * @return array 91 | */ 92 | public static function getExtractedImageConfig(): array 93 | { 94 | return [ 95 | 'timeout' => 15, 96 | 'connect_timeout' => 10, 97 | 'http_errors' => false, 98 | 'allow_redirects' => [ 99 | 'max' => 5, 100 | 'strict' => false, 101 | 'referer' => true, 102 | 'protocols' => ['http', 'https'], 103 | 'track_redirects' => false 104 | ], 105 | 'headers' => [ 106 | 'User-Agent' => self::getRandomUserAgent(), 107 | 'Accept' => 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', 108 | 'Accept-Encoding' => 'gzip, deflate, br', 109 | 'Accept-Language' => 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 110 | ], 111 | 'verify' => true, 112 | 'decode_content' => 'gzip' 113 | ]; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/src/Services/ProxyService.php: -------------------------------------------------------------------------------- 1 | cacheFile = __DIR__ . '/../../storage/proxy_list.cache'; 18 | $this->httpClient = new Client([ 19 | 'timeout' => 10, 20 | 'connect_timeout' => 5, 21 | 'http_errors' => false 22 | ]); 23 | 24 | $storageDir = dirname($this->cacheFile); 25 | if (!is_dir($storageDir)) { 26 | mkdir($storageDir, 0755, true); 27 | } 28 | 29 | $this->loadCachedProxyList(); 30 | } 31 | 32 | public function getRandomProxy(): ?array 33 | { 34 | if (empty($this->proxyList)) { 35 | $this->fetchProxyList(); 36 | } 37 | 38 | if (empty($this->proxyList)) { 39 | return null; 40 | } 41 | 42 | return $this->proxyList[array_rand($this->proxyList)]; 43 | } 44 | 45 | public function fetchProxyList(): bool 46 | { 47 | $proxyListUrl = $_ENV['PROXY_LIST'] ?? null; 48 | 49 | if (empty($proxyListUrl)) { 50 | return false; 51 | } 52 | 53 | try { 54 | $response = $this->httpClient->get($proxyListUrl); 55 | 56 | if ($response->getStatusCode() !== 200) { 57 | return false; 58 | } 59 | 60 | $content = (string) $response->getBody(); 61 | $lines = explode("\n", $content); 62 | $proxies = []; 63 | 64 | foreach ($lines as $line) { 65 | $line = trim($line); 66 | if (empty($line)) { 67 | continue; 68 | } 69 | 70 | $proxy = $this->parseProxyString($line); 71 | if ($proxy) { 72 | $proxies[] = $proxy; 73 | } 74 | } 75 | 76 | if (!empty($proxies)) { 77 | $this->proxyList = $proxies; 78 | $this->saveCachedProxyList($proxies); 79 | return true; 80 | } 81 | 82 | return false; 83 | } catch (GuzzleException $e) { 84 | error_log("Error fetching proxy list: " . $e->getMessage()); 85 | return false; 86 | } 87 | } 88 | 89 | public function loadCachedProxyList(): bool 90 | { 91 | if (!file_exists($this->cacheFile)) { 92 | return false; 93 | } 94 | 95 | $content = file_get_contents($this->cacheFile); 96 | if ($content === false) { 97 | return false; 98 | } 99 | 100 | $proxies = json_decode($content, true); 101 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($proxies)) { 102 | return false; 103 | } 104 | 105 | $this->proxyList = $proxies; 106 | return true; 107 | } 108 | 109 | public function saveCachedProxyList(array $proxies): bool 110 | { 111 | $content = json_encode($proxies); 112 | if ($content === false) { 113 | return false; 114 | } 115 | 116 | return file_put_contents($this->cacheFile, $content) !== false; 117 | } 118 | 119 | public function parseProxyString(string $proxyString): ?array 120 | { 121 | if (preg_match('/^([^:]+):(\d+):([^:]+):(.+)$/', $proxyString, $matches)) { 122 | return [ 123 | 'host' => $matches[1], 124 | 'port' => (int) $matches[2], 125 | 'username' => $matches[3], 126 | 'password' => $matches[4] 127 | ]; 128 | } 129 | 130 | if (preg_match('/^([^@]+)@([^:]+):([^:]+):(\d+)$/', $proxyString, $matches)) { 131 | return [ 132 | 'host' => $matches[3], 133 | 'port' => (int) $matches[4], 134 | 'username' => $matches[1], 135 | 'password' => $matches[2] 136 | ]; 137 | } 138 | 139 | if (preg_match('/^([^:]+):(\d+)$/', $proxyString, $matches)) { 140 | return [ 141 | 'host' => $matches[1], 142 | 'port' => (int) $matches[2], 143 | 'username' => null, 144 | 'password' => null 145 | ]; 146 | } 147 | 148 | return null; 149 | } 150 | } -------------------------------------------------------------------------------- /app/templates/admin/categories.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>admin-categoriesstop() ?> 4 | 5 |
6 |
7 |
8 |

9 | 10 | 11 |

12 |
13 | 19 |
20 | 21 | 22 |
23 |

24 | 25 | 26 |

27 |
28 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 46 | 49 | 54 | 64 | 65 | 66 | 67 |
44 | e($category['name']) ?> 45 | 47 | e($category['slug']) ?> 48 | 50 | 51 | 52 | 53 | 55 |
56 | 57 | 58 | 59 | 62 |
63 |
68 |
69 |
70 | 71 |
72 | 73 | start('scripts') ?> 74 | 100 | stop() ?> -------------------------------------------------------------------------------- /app/bin/lerama: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | load(); 16 | 17 | DB::$host = $_ENV['LERAMA_DB_HOST']; 18 | DB::$user = $_ENV['LERAMA_DB_USER']; 19 | DB::$password = $_ENV['LERAMA_DB_PASS']; 20 | DB::$dbName = $_ENV['LERAMA_DB_NAME']; 21 | DB::$port = (int)$_ENV['LERAMA_DB_PORT']; 22 | DB::$encoding = 'utf8mb4'; 23 | 24 | $climate = new CLImate(); 25 | 26 | $processor = new FeedProcessor($climate); 27 | 28 | $command = $argv[1] ?? ''; 29 | 30 | if (empty($command)) { 31 | showUsage($climate); 32 | exit(1); 33 | } 34 | 35 | switch ($command) { 36 | case 'feed:process': 37 | // Default to FEED_WORKERS env variable, or 1 if not set 38 | $parallel = isset($_ENV['FEED_WORKERS']) ? max(1, min(10, (int)$_ENV['FEED_WORKERS'])) : 1; 39 | 40 | // Check for --parallel or -p flag (overrides env variable) 41 | for ($i = 2; $i < count($argv); $i++) { 42 | if (in_array($argv[$i], ['--parallel', '-p']) && isset($argv[$i + 1])) { 43 | $parallel = max(1, min(10, (int)$argv[$i + 1])); 44 | break; 45 | } 46 | } 47 | 48 | if ($parallel > 1) { 49 | $climate->info("Processing feeds with {$parallel} parallel workers"); 50 | } 51 | 52 | $processor->process(null, $parallel); 53 | break; 54 | 55 | case 'feed:id': 56 | // Process specific feed by ID 57 | $feedId = $argv[2] ?? null; 58 | 59 | if (empty($feedId) || !is_numeric($feedId)) { 60 | $climate->error("Feed ID is required and must be a number"); 61 | exit(1); 62 | } 63 | 64 | $processor->process((int)$feedId, 1); 65 | break; 66 | 67 | case 'feed:check-status': 68 | // Check and update status of paused feeds 69 | $processor->checkPausedFeeds(); 70 | break; 71 | 72 | case 'feed:check-real-content': 73 | // Check all feed items for real contents 74 | $processor->checkItemsContent(); 75 | break; 76 | 77 | case 'proxy:update': 78 | // Update proxy list 79 | $climate->info("Updating proxy list..."); 80 | $proxyService = new ProxyService(); 81 | if ($proxyService->fetchProxyList()) { 82 | $climate->green("Proxy list updated successfully"); 83 | } else { 84 | $climate->red("Failed to update proxy list"); 85 | exit(1); 86 | } 87 | break; 88 | 89 | case 'feed:import': 90 | // Import feeds from CSV 91 | $csvPath = $argv[2] ?? null; 92 | 93 | if (empty($csvPath)) { 94 | $climate->error("CSV file path is required"); 95 | $climate->out("Usage: php bin/lerama feed:import "); 96 | exit(1); 97 | } 98 | 99 | $importer = new FeedImporter($climate); 100 | $importer->import($csvPath); 101 | break; 102 | 103 | default: 104 | $climate->error("Unknown command: {$command}"); 105 | showUsage($climate); 106 | exit(1); 107 | } 108 | 109 | function showUsage(CLImate $climate): void 110 | { 111 | $climate->out("Usage:"); 112 | $climate->out(" php bin/lerama feed:process [--parallel|-p N] Process all feeds (optionally with N parallel workers, max 10)"); 113 | $climate->out(" php bin/lerama feed:id {ID_DO_FEED} Process a specific feed by ID"); 114 | $climate->out(" php bin/lerama feed:check-status Check and update status of paused feeds"); 115 | $climate->out(" php bin/lerama feed:check-real-content Check all feed items for real contents"); 116 | $climate->out(" php bin/lerama feed:import Import feeds from CSV file (columns: url, tags, category)"); 117 | $climate->out(" php bin/lerama proxy:update Update proxy list from PROXY_LIST URL"); 118 | $climate->out(""); 119 | $climate->out("Examples:"); 120 | $climate->out(" php bin/lerama feed:process Process all feeds sequentially"); 121 | $climate->out(" php bin/lerama feed:process --parallel 5 Process feeds with 5 parallel workers"); 122 | $climate->out(" php bin/lerama feed:process -p 3 Process feeds with 3 parallel workers"); 123 | $climate->out(" php bin/lerama feed:import feeds.csv Import feeds from CSV file"); 124 | $climate->out(" php bin/lerama feed:check-real-content Check for real content"); 125 | } 126 | -------------------------------------------------------------------------------- /app/src/Services/EmailService.php: -------------------------------------------------------------------------------- 1 | enabled = !empty($_ENV['SMTP_HOST']) && !empty($_ENV['SMTP_PORT']); 19 | $this->adminEmail = $_ENV['ADMIN_EMAIL'] ?? ''; 20 | $this->notifyRegistrationEmail = $_ENV['APP_NOTIFY_REGISTRATION'] ?? ''; 21 | 22 | if ($this->enabled) { 23 | $this->mailer = new PHPMailer(true); 24 | 25 | $this->mailer->isSMTP(); 26 | $this->mailer->Host = $_ENV['SMTP_HOST']; 27 | $this->mailer->Port = (int)$_ENV['SMTP_PORT']; 28 | $this->mailer->SMTPAuth = !empty($_ENV['SMTP_USERNAME']) && !empty($_ENV['SMTP_PASSWORD']); 29 | 30 | if ($this->mailer->SMTPAuth) { 31 | $this->mailer->Username = $_ENV['SMTP_USERNAME']; 32 | $this->mailer->Password = $_ENV['SMTP_PASSWORD']; 33 | } 34 | 35 | $this->mailer->SMTPSecure = $_ENV['SMTP_SECURE'] ?? PHPMailer::ENCRYPTION_STARTTLS; 36 | $this->mailer->setFrom($_ENV['SMTP_FROM_EMAIL'], $_ENV['SMTP_FROM_NAME']); 37 | } 38 | } 39 | 40 | public function sendFeedOfflineNotification(array $feed): bool 41 | { 42 | if (!$this->enabled) { 43 | error_log("Feed marked as offline: {$feed['title']} ({$feed['feed_url']})"); 44 | return false; 45 | } 46 | 47 | try { 48 | $this->mailer->clearAddresses(); 49 | $this->mailer->addAddress($this->adminEmail); 50 | $this->mailer->Subject = "[Lerama] Feed offline: {$feed['title']}"; 51 | 52 | $body = "

Feed marcado como offline

"; 53 | $body .= "

Detalhes do feed

"; 54 | $body .= "
    "; 55 | $body .= "
  • Título: {$feed['title']}
  • "; 56 | $body .= "
  • URL: {$feed['feed_url']}
  • "; 57 | $body .= "
  • Tipo: {$feed['feed_type']}
  • "; 58 | $body .= "
  • Última verificação: {$feed['last_checked']}
  • "; 59 | $body .= "
"; 60 | $body .= "

O feed foi pausado inicialmente em {$feed['paused_at']} e está inacessível há mais de 72 horas.

"; 61 | 62 | $this->mailer->isHTML(true); 63 | $this->mailer->Body = $body; 64 | $this->mailer->AltBody = strip_tags(str_replace(['
  • ', '
  • '], ["\n- ", ''], $body)); 65 | 66 | return $this->mailer->send(); 67 | } catch (Exception $e) { 68 | error_log("Error sending feed offline notification: " . $e->getMessage()); 69 | return false; 70 | } 71 | } 72 | 73 | public function sendFeedRegistrationNotification(array $feed): bool 74 | { 75 | if (!$this->enabled || empty($this->notifyRegistrationEmail)) { 76 | error_log("New feed registered: {$feed['title']} ({$feed['feed_url']})"); 77 | return false; 78 | } 79 | 80 | try { 81 | $this->mailer->clearAddresses(); 82 | $this->mailer->addAddress($this->notifyRegistrationEmail); 83 | $this->mailer->Subject = "[Lerama] Novo feed registrado: {$feed['title']}"; 84 | 85 | $body = "

    Novo feed registrado

    "; 86 | $body .= "

    Detalhes do feed

    "; 87 | $body .= "
      "; 88 | $body .= "
    • Título: {$feed['title']}
    • "; 89 | $body .= "
    • URL do Feed: {$feed['feed_url']}
    • "; 90 | $body .= "
    • URL do Site: {$feed['site_url']}
    • "; 91 | $body .= "
    • Tipo: {$feed['feed_type']}
    • "; 92 | $body .= "
    • Idioma: {$feed['language']}
    • "; 93 | $body .= "
    • Status: {$feed['status']}
    • "; 94 | $body .= "
    • Data de registro: " . date('d/m/Y H:i:s') . "
    • "; 95 | $body .= "
    "; 96 | 97 | $this->mailer->isHTML(true); 98 | $this->mailer->Body = $body; 99 | $this->mailer->AltBody = strip_tags(str_replace(['
  • ', '
  • '], ["\n- ", ''], $body)); 100 | 101 | return $this->mailer->send(); 102 | } catch (Exception $e) { 103 | error_log("Error sending feed registration notification: " . $e->getMessage()); 104 | return false; 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /app/setup/2025-11-03.sql: -------------------------------------------------------------------------------- 1 | -- Migration for Category and Tag Item Counts 2 | -- Date: 2025-11-03 3 | -- Adds item_count columns and triggers to automatically maintain counts 4 | 5 | SET NAMES utf8mb4; 6 | SET FOREIGN_KEY_CHECKS = 0; 7 | 8 | -- Add item_count column to categories table 9 | ALTER TABLE `categories` 10 | ADD COLUMN IF NOT EXISTS `item_count` INT UNSIGNED DEFAULT 0 AFTER `slug`; 11 | 12 | -- Add item_count column to tags table 13 | ALTER TABLE `tags` 14 | ADD COLUMN IF NOT EXISTS `item_count` INT UNSIGNED DEFAULT 0 AFTER `slug`; 15 | 16 | -- Initialize counts for existing categories 17 | UPDATE `categories` c 18 | SET c.item_count = ( 19 | SELECT COUNT(DISTINCT fi.id) 20 | FROM feed_items fi 21 | JOIN feeds f ON fi.feed_id = f.id 22 | JOIN feed_categories fc ON f.id = fc.feed_id 23 | WHERE fc.category_id = c.id 24 | AND fi.is_visible = 1 25 | ); 26 | 27 | -- Initialize counts for existing tags 28 | UPDATE `tags` t 29 | SET t.item_count = ( 30 | SELECT COUNT(DISTINCT fi.id) 31 | FROM feed_items fi 32 | JOIN feeds f ON fi.feed_id = f.id 33 | JOIN feed_tags ft ON f.id = ft.feed_id 34 | WHERE ft.tag_id = t.id 35 | AND fi.is_visible = 1 36 | ); 37 | 38 | -- Drop existing triggers if they exist 39 | DROP TRIGGER IF EXISTS `update_category_count_on_insert`; 40 | DROP TRIGGER IF EXISTS `update_category_count_on_delete`; 41 | DROP TRIGGER IF EXISTS `update_category_count_on_update`; 42 | DROP TRIGGER IF EXISTS `update_tag_count_on_insert`; 43 | DROP TRIGGER IF EXISTS `update_tag_count_on_delete`; 44 | DROP TRIGGER IF EXISTS `update_tag_count_on_update`; 45 | 46 | -- Trigger to update category counts when feed_item is inserted 47 | CREATE TRIGGER `update_category_count_on_insert` 48 | AFTER INSERT ON `feed_items` 49 | FOR EACH ROW 50 | BEGIN 51 | IF NEW.is_visible = 1 THEN 52 | UPDATE `categories` c 53 | INNER JOIN `feed_categories` fc ON c.id = fc.category_id 54 | SET c.item_count = c.item_count + 1 55 | WHERE fc.feed_id = NEW.feed_id; 56 | END IF; 57 | END; 58 | 59 | -- Trigger to update category counts when feed_item is deleted 60 | CREATE TRIGGER `update_category_count_on_delete` 61 | AFTER DELETE ON `feed_items` 62 | FOR EACH ROW 63 | BEGIN 64 | IF OLD.is_visible = 1 THEN 65 | UPDATE `categories` c 66 | INNER JOIN `feed_categories` fc ON c.id = fc.category_id 67 | SET c.item_count = GREATEST(0, c.item_count - 1) 68 | WHERE fc.feed_id = OLD.feed_id; 69 | END IF; 70 | END; 71 | 72 | -- Trigger to update category counts when feed_item visibility changes 73 | CREATE TRIGGER `update_category_count_on_update` 74 | AFTER UPDATE ON `feed_items` 75 | FOR EACH ROW 76 | BEGIN 77 | IF OLD.is_visible != NEW.is_visible THEN 78 | IF NEW.is_visible = 1 THEN 79 | -- Item became visible, increment count 80 | UPDATE `categories` c 81 | INNER JOIN `feed_categories` fc ON c.id = fc.category_id 82 | SET c.item_count = c.item_count + 1 83 | WHERE fc.feed_id = NEW.feed_id; 84 | ELSE 85 | -- Item became invisible, decrement count 86 | UPDATE `categories` c 87 | INNER JOIN `feed_categories` fc ON c.id = fc.category_id 88 | SET c.item_count = GREATEST(0, c.item_count - 1) 89 | WHERE fc.feed_id = NEW.feed_id; 90 | END IF; 91 | END IF; 92 | END; 93 | 94 | -- Trigger to update tag counts when feed_item is inserted 95 | CREATE TRIGGER `update_tag_count_on_insert` 96 | AFTER INSERT ON `feed_items` 97 | FOR EACH ROW 98 | BEGIN 99 | IF NEW.is_visible = 1 THEN 100 | UPDATE `tags` t 101 | INNER JOIN `feed_tags` ft ON t.id = ft.tag_id 102 | SET t.item_count = t.item_count + 1 103 | WHERE ft.feed_id = NEW.feed_id; 104 | END IF; 105 | END; 106 | 107 | -- Trigger to update tag counts when feed_item is deleted 108 | CREATE TRIGGER `update_tag_count_on_delete` 109 | AFTER DELETE ON `feed_items` 110 | FOR EACH ROW 111 | BEGIN 112 | IF OLD.is_visible = 1 THEN 113 | UPDATE `tags` t 114 | INNER JOIN `feed_tags` ft ON t.id = ft.tag_id 115 | SET t.item_count = GREATEST(0, t.item_count - 1) 116 | WHERE ft.feed_id = OLD.feed_id; 117 | END IF; 118 | END; 119 | 120 | -- Trigger to update tag counts when feed_item visibility changes 121 | CREATE TRIGGER `update_tag_count_on_update` 122 | AFTER UPDATE ON `feed_items` 123 | FOR EACH ROW 124 | BEGIN 125 | IF OLD.is_visible != NEW.is_visible THEN 126 | IF NEW.is_visible = 1 THEN 127 | -- Item became visible, increment count 128 | UPDATE `tags` t 129 | INNER JOIN `feed_tags` ft ON t.id = ft.tag_id 130 | SET t.item_count = t.item_count + 1 131 | WHERE ft.feed_id = NEW.feed_id; 132 | ELSE 133 | -- Item became invisible, decrement count 134 | UPDATE `tags` t 135 | INNER JOIN `feed_tags` ft ON t.id = ft.tag_id 136 | SET t.item_count = GREATEST(0, t.item_count - 1) 137 | WHERE ft.feed_id = NEW.feed_id; 138 | END IF; 139 | END IF; 140 | END; 141 | 142 | SET FOREIGN_KEY_CHECKS = 1; -------------------------------------------------------------------------------- /app/public/index.php: -------------------------------------------------------------------------------- 1 | load(); 19 | 20 | // Initialize translator 21 | require_once __DIR__ . '/../src/Services/Translator.php'; 22 | \App\Services\Translator::getInstance(); 23 | 24 | // Initialize database connection 25 | DB::$host = $_ENV['LERAMA_DB_HOST']; 26 | DB::$user = $_ENV['LERAMA_DB_USER']; 27 | DB::$password = $_ENV['LERAMA_DB_PASS']; 28 | DB::$dbName = $_ENV['LERAMA_DB_NAME']; 29 | DB::$port = (int)$_ENV['LERAMA_DB_PORT']; 30 | DB::$encoding = 'utf8mb4'; 31 | 32 | // Create the router 33 | $router = new Router(); 34 | 35 | // Define routes 36 | // Public routes 37 | $router->map('GET', '/', [HomeController::class, 'index']); 38 | $router->map('GET', '/page/{page:number}', [HomeController::class, 'index']); 39 | $router->map('GET', '/feeds', [FeedController::class, 'index']); 40 | $router->map('GET', '/feeds/page/{page:number}', [FeedController::class, 'index']); 41 | $router->map('GET', '/categories', [HomeController::class, 'categories']); 42 | $router->map('GET', '/tags', [HomeController::class, 'tags']); 43 | $router->map('GET', '/feed-builder', [FeedController::class, 'feedBuilder']); 44 | $router->map('GET', '/feed', [FeedController::class, 'rss']); 45 | $router->map('GET', '/feed/json', [FeedController::class, 'json']); 46 | $router->map('GET', '/feed/rss', [FeedController::class, 'rss']); 47 | $router->map('GET', '/suggest-feed', [SuggestionController::class, 'suggestForm']); 48 | $router->map('POST', '/suggest-feed', [SuggestionController::class, 'submitSuggestion']); 49 | $router->map('GET', '/captcha', [SuggestionController::class, 'getCaptcha']); 50 | $router->map('GET', '/admin/login', [AdminController::class, 'loginForm']); 51 | $router->map('POST', '/admin/login', [AdminController::class, 'login']); 52 | 53 | // Admin routes (protected by middleware) 54 | $router->group('/admin', function ($router) { 55 | $router->map('GET', '/', [AdminController::class, 'index']); 56 | 57 | // Feeds management 58 | $router->map('GET', '/feeds', [AdminController::class, 'feeds']); 59 | $router->map('GET', '/feeds/new', [AdminController::class, 'newFeedForm']); 60 | $router->map('POST', '/feeds/new', [AdminController::class, 'newFeedForm']); 61 | $router->map('GET', '/feeds/{id:number}/edit', [AdminController::class, 'editFeedForm']); 62 | $router->map('POST', '/feeds/{id:number}/edit', [AdminController::class, 'editFeedForm']); 63 | $router->map('POST', '/feeds', [AdminController::class, 'createFeed']); 64 | $router->map('PUT', '/feeds/{id:number}', [AdminController::class, 'updateFeed']); 65 | $router->map('DELETE', '/feeds/{id:number}', [AdminController::class, 'deleteFeed']); 66 | $router->map('POST', '/feeds/bulk/categories', [AdminController::class, 'bulkUpdateFeedCategories']); 67 | $router->map('POST', '/feeds/bulk/tags', [AdminController::class, 'bulkUpdateFeedTags']); 68 | $router->map('POST', '/feeds/bulk/status', [AdminController::class, 'bulkUpdateFeedStatus']); 69 | $router->map('PUT', '/items/{id:number}', [AdminController::class, 'updateItem']); 70 | 71 | // Categories management 72 | $router->map('GET', '/categories', [AdminController::class, 'categories']); 73 | $router->map('GET', '/categories/new', [AdminController::class, 'newCategoryForm']); 74 | $router->map('POST', '/categories/new', [AdminController::class, 'newCategoryForm']); 75 | $router->map('GET', '/categories/{id:number}/edit', [AdminController::class, 'editCategoryForm']); 76 | $router->map('POST', '/categories/{id:number}/edit', [AdminController::class, 'editCategoryForm']); 77 | $router->map('POST', '/categories', [AdminController::class, 'createCategory']); 78 | $router->map('PUT', '/categories/{id:number}', [AdminController::class, 'updateCategory']); 79 | $router->map('DELETE', '/categories/{id:number}', [AdminController::class, 'deleteCategory']); 80 | 81 | // Tags management 82 | $router->map('GET', '/tags', [AdminController::class, 'tags']); 83 | $router->map('GET', '/tags/new', [AdminController::class, 'newTagForm']); 84 | $router->map('POST', '/tags/new', [AdminController::class, 'newTagForm']); 85 | $router->map('GET', '/tags/{id:number}/edit', [AdminController::class, 'editTagForm']); 86 | $router->map('POST', '/tags/{id:number}/edit', [AdminController::class, 'editTagForm']); 87 | $router->map('POST', '/tags', [AdminController::class, 'createTag']); 88 | $router->map('PUT', '/tags/{id:number}', [AdminController::class, 'updateTag']); 89 | $router->map('DELETE', '/tags/{id:number}', [AdminController::class, 'deleteTag']); 90 | 91 | $router->map('GET', '/logout', [AdminController::class, 'logout']); 92 | })->middleware(new AuthMiddleware()); 93 | 94 | // Process the request 95 | $request = ServerRequestFactory::fromGlobals(); 96 | $response = $router->dispatch($request); 97 | 98 | // Emit the response 99 | (new SapiEmitter())->emit($response); -------------------------------------------------------------------------------- /app/src/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | templates = new Engine(__DIR__ . '/../../templates'); 23 | $this->thumbnailService = new ThumbnailService(); 24 | } 25 | 26 | public function index(ServerRequestInterface $request, array $args = []): ResponseInterface 27 | { 28 | $page = isset($args['page']) ? (int)$args['page'] : 1; 29 | if ($page < 1) { 30 | $page = 1; 31 | } 32 | 33 | $perPage = 20; 34 | $offset = ($page - 1) * $perPage; 35 | 36 | $params = $request->getQueryParams(); 37 | $search = $params['search'] ?? ''; 38 | $feedId = isset($params['feed']) ? (int)$params['feed'] : null; 39 | $categorySlug = $params['category'] ?? null; 40 | $tagSlug = $params['tag'] ?? null; 41 | 42 | $query = "SELECT fi.*, f.title as feed_title, f.site_url, f.language 43 | FROM feed_items fi 44 | JOIN feeds f ON fi.feed_id = f.id 45 | WHERE fi.is_visible = 1"; 46 | $countQuery = "SELECT COUNT(*) FROM feed_items fi 47 | JOIN feeds f ON fi.feed_id = f.id 48 | WHERE fi.is_visible = 1"; 49 | $queryParams = []; 50 | 51 | if (!empty($search)) { 52 | $query .= " AND MATCH(fi.title, fi.content) AGAINST (%s IN BOOLEAN MODE)"; 53 | $countQuery .= " AND MATCH(fi.title, fi.content) AGAINST (%s IN BOOLEAN MODE)"; 54 | $queryParams[] = $search; 55 | } 56 | 57 | if ($feedId) { 58 | $query .= " AND fi.feed_id = %i"; 59 | $countQuery .= " AND fi.feed_id = %i"; 60 | $queryParams[] = $feedId; 61 | } 62 | 63 | if ($categorySlug) { 64 | $query .= " AND EXISTS ( 65 | SELECT 1 FROM feed_categories fc 66 | JOIN categories c ON fc.category_id = c.id 67 | WHERE fc.feed_id = f.id AND c.slug = %s 68 | )"; 69 | $countQuery .= " AND EXISTS ( 70 | SELECT 1 FROM feed_categories fc 71 | JOIN categories c ON fc.category_id = c.id 72 | WHERE fc.feed_id = f.id AND c.slug = %s 73 | )"; 74 | $queryParams[] = $categorySlug; 75 | } 76 | 77 | if ($tagSlug) { 78 | $query .= " AND EXISTS ( 79 | SELECT 1 FROM feed_tags ft 80 | JOIN tags t ON ft.tag_id = t.id 81 | WHERE ft.feed_id = f.id AND t.slug = %s 82 | )"; 83 | $countQuery .= " AND EXISTS ( 84 | SELECT 1 FROM feed_tags ft 85 | JOIN tags t ON ft.tag_id = t.id 86 | WHERE ft.feed_id = f.id AND t.slug = %s 87 | )"; 88 | $queryParams[] = $tagSlug; 89 | } 90 | 91 | $totalCount = DB::queryFirstField($countQuery, ...$queryParams); 92 | $totalPages = ceil($totalCount / $perPage); 93 | if ($page > $totalPages && $totalPages > 0) { 94 | return new RedirectResponse('/page/' . $totalPages . $this->buildQueryString($params)); 95 | } 96 | 97 | $query .= " ORDER BY fi.published_at DESC LIMIT %i, %i"; 98 | $finalQueryParams = [...$queryParams, $offset, $perPage]; 99 | 100 | $items = DB::query($query, ...$finalQueryParams); 101 | 102 | $feeds = DB::query("SELECT id, title FROM feeds ORDER BY title"); 103 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 104 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 105 | 106 | $html = $this->templates->render('home', [ 107 | 'items' => $items, 108 | 'feeds' => $feeds, 109 | 'categories' => $categories, 110 | 'tags' => $tags, 111 | 'search' => $search, 112 | 'selectedFeed' => $feedId, 113 | 'selectedCategory' => $categorySlug, 114 | 'selectedTag' => $tagSlug, 115 | 'pagination' => [ 116 | 'current' => $page, 117 | 'total' => $totalPages, 118 | 'baseUrl' => '/page/' 119 | ], 120 | 'title' => 'Últimos Artigos', 121 | 'thumbnailService' => $this->thumbnailService 122 | ]); 123 | 124 | return new HtmlResponse($html); 125 | } 126 | 127 | private function buildQueryString(array $params): string 128 | { 129 | if (empty($params)) { 130 | return ''; 131 | } 132 | 133 | unset($params['page']); 134 | 135 | return '?' . http_build_query($params); 136 | } 137 | 138 | public function categories(ServerRequestInterface $request): ResponseInterface 139 | { 140 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 141 | 142 | $html = $this->templates->render('categories-list', [ 143 | 'categories' => $categories, 144 | 'title' => 'Categorias' 145 | ]); 146 | 147 | return new HtmlResponse($html); 148 | } 149 | 150 | public function tags(ServerRequestInterface $request): ResponseInterface 151 | { 152 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 153 | 154 | $html = $this->templates->render('tags-list', [ 155 | 'tags' => $tags, 156 | 'title' => 'Tópicos' 157 | ]); 158 | 159 | return new HtmlResponse($html); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Main 2 | run-name: 🚀 Version Deployment 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*.*.*' 8 | 9 | env: 10 | DOCKER_REGISTRY: ghcr.io 11 | DOCKER_IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | docker-build-amd64: 15 | name: 🐳 Build and Push (AMD64) 16 | runs-on: ubuntu-24.04 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: 📥 Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: 🏷️ Extract version from tag 26 | id: get_version 27 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 28 | 29 | - name: 🔧 Set up QEMU 30 | uses: docker/setup-qemu-action@v3 31 | 32 | - name: 🛠️ Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: 📋 Extract Docker metadata 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: | 40 | ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }} 41 | ${{ env.DOCKERHUB_REPOSITORY }} 42 | tags: | 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | type=sha 46 | type=raw,value=latest 47 | flavor: | 48 | suffix=-amd64 49 | 50 | - name: 🔐 Log in to GitHub Registry 51 | uses: docker/login-action@v3 52 | with: 53 | registry: ${{ env.DOCKER_REGISTRY }} 54 | username: ${{ github.actor }} 55 | password: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | - name: 🏗️ Build and Push (AMD64) 58 | uses: docker/build-push-action@v5 59 | with: 60 | context: . 61 | platforms: linux/amd64 62 | push: true 63 | tags: ${{ steps.meta.outputs.tags }} 64 | labels: ${{ steps.meta.outputs.labels }} 65 | cache-from: type=gha,scope=amd64 66 | cache-to: type=gha,mode=max,scope=amd64 67 | 68 | docker-build-arm64: 69 | name: 🐳 Build and Push (ARM64) 70 | runs-on: ubuntu-24.04-arm 71 | permissions: 72 | contents: read 73 | packages: write 74 | 75 | steps: 76 | - name: 📥 Checkout code 77 | uses: actions/checkout@v4 78 | 79 | - name: 🏷️ Extract version from tag 80 | id: get_version 81 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 82 | 83 | - name: 🛠️ Set up Docker Buildx 84 | uses: docker/setup-buildx-action@v3 85 | 86 | - name: 📋 Extract Docker metadata 87 | id: meta 88 | uses: docker/metadata-action@v5 89 | with: 90 | images: | 91 | ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }} 92 | ${{ env.DOCKERHUB_REPOSITORY }} 93 | tags: | 94 | type=semver,pattern={{version}} 95 | type=semver,pattern={{major}}.{{minor}} 96 | type=sha 97 | type=raw,value=latest 98 | flavor: | 99 | suffix=-arm64 100 | 101 | - name: 🔐 Log in to GitHub Registry 102 | uses: docker/login-action@v3 103 | with: 104 | registry: ${{ env.DOCKER_REGISTRY }} 105 | username: ${{ github.actor }} 106 | password: ${{ secrets.GITHUB_TOKEN }} 107 | 108 | - name: 🏗️ Build and Push (ARM64) 109 | uses: docker/build-push-action@v5 110 | with: 111 | context: . 112 | platforms: linux/arm64 113 | push: true 114 | tags: ${{ steps.meta.outputs.tags }} 115 | labels: ${{ steps.meta.outputs.labels }} 116 | cache-from: type=gha,scope=arm64 117 | cache-to: type=gha,mode=max,scope=arm64 118 | 119 | docker-manifest: 120 | name: 🔗 Create Multi-Platform Manifest 121 | runs-on: ubuntu-24.04 122 | needs: [docker-build-amd64, docker-build-arm64] 123 | permissions: 124 | contents: read 125 | packages: write 126 | 127 | steps: 128 | - name: 📥 Checkout code 129 | uses: actions/checkout@v4 130 | 131 | - name: 🏷️ Extract version from tag 132 | id: get_version 133 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 134 | 135 | - name: 📋 Extract Docker metadata 136 | id: meta 137 | uses: docker/metadata-action@v5 138 | with: 139 | images: | 140 | ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }} 141 | ${{ env.DOCKERHUB_REPOSITORY }} 142 | tags: | 143 | type=semver,pattern={{version}} 144 | type=semver,pattern={{major}}.{{minor}} 145 | type=sha 146 | type=raw,value=latest 147 | 148 | - name: 🔐 Log in to GitHub Registry 149 | uses: docker/login-action@v3 150 | with: 151 | registry: ${{ env.DOCKER_REGISTRY }} 152 | username: ${{ github.actor }} 153 | password: ${{ secrets.GITHUB_TOKEN }} 154 | 155 | - name: 🛠️ Set up Docker Buildx 156 | uses: docker/setup-buildx-action@v3 157 | 158 | - name: 🔗 Create and Push Multi-Platform Manifest 159 | run: | 160 | # Extract the main tags (without suffixes) 161 | TAGS="${{ steps.meta.outputs.tags }}" 162 | 163 | for tag in $TAGS; do 164 | echo "Creating manifest for $tag" 165 | docker buildx imagetools create \ 166 | --tag $tag \ 167 | $tag-amd64 \ 168 | $tag-arm64 169 | done 170 | 171 | publish-release: 172 | name: 📦 Publish Release 173 | runs-on: ubuntu-24.04 174 | needs: [docker-build-amd64, docker-build-arm64, docker-manifest] 175 | permissions: 176 | contents: write 177 | 178 | steps: 179 | - name: 📥 Checkout code 180 | uses: actions/checkout@v4 181 | 182 | - name: 🏷️ Extract version from tag 183 | id: get_version 184 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 185 | 186 | - name: 📝 Create Release 187 | uses: softprops/action-gh-release@v1 188 | with: 189 | name: "🎉 Release v${{ steps.get_version.outputs.VERSION }}" 190 | tag_name: ${{ steps.get_version.outputs.VERSION }} 191 | generate_release_notes: true 192 | draft: false 193 | prerelease: false -------------------------------------------------------------------------------- /app/src/Services/ThumbnailService.php: -------------------------------------------------------------------------------- 1 | thumbnailDir = __DIR__ . '/../../public/storage/thumbnails/'; 18 | $this->client = new Client(); 19 | 20 | if (!is_dir($this->thumbnailDir)) { 21 | @mkdir($this->thumbnailDir, 0755, true); 22 | } 23 | } 24 | 25 | public function getThumbnail(string $imageUrl, int $width = 120, int $height = 60): string 26 | { 27 | if (empty($imageUrl)) { 28 | return ''; 29 | } 30 | 31 | $filename = md5($imageUrl . $width . $height) . '.jpg'; 32 | $thumbnailPath = $this->thumbnailDir . $filename; 33 | $thumbnailUrl = '/storage/thumbnails/' . $filename; 34 | 35 | if (file_exists($thumbnailPath)) { 36 | return $thumbnailUrl; 37 | } 38 | 39 | try { 40 | $tempFile = $this->downloadImage($imageUrl); 41 | 42 | if (!$tempFile) { 43 | return $imageUrl; 44 | } 45 | 46 | $this->createThumbnail($tempFile, $thumbnailPath, $width, $height); 47 | 48 | if (file_exists($tempFile)) { 49 | unlink($tempFile); 50 | } 51 | 52 | return $thumbnailUrl; 53 | } catch (\Exception $e) { 54 | return $imageUrl; 55 | } 56 | } 57 | 58 | private function downloadImage(string $url): ?string 59 | { 60 | try { 61 | $tempFile = tempnam(sys_get_temp_dir(), 'img_'); 62 | 63 | $response = $this->client->get($url, [ 64 | 'sink' => $tempFile, 65 | 'timeout' => 10, 66 | 'connect_timeout' => 5 67 | ]); 68 | 69 | if ($response->getStatusCode() === 200) { 70 | return $tempFile; 71 | } 72 | 73 | if (file_exists($tempFile)) { 74 | unlink($tempFile); 75 | } 76 | 77 | return null; 78 | } catch (GuzzleException $e) { 79 | if (isset($tempFile) && file_exists($tempFile)) { 80 | unlink($tempFile); 81 | } 82 | 83 | return null; 84 | } 85 | } 86 | 87 | private function createThumbnail(string $sourcePath, string $destPath, int $maxWidth, int $maxHeight): bool 88 | { 89 | $imageInfo = getimagesize($sourcePath); 90 | if ($imageInfo === false) { 91 | return false; 92 | } 93 | 94 | $sourceWidth = $imageInfo[0]; 95 | $sourceHeight = $imageInfo[1]; 96 | $mimeType = $imageInfo['mime']; 97 | 98 | $sourceImage = $this->createImageFromFile($sourcePath, $mimeType); 99 | if (!$sourceImage) { 100 | return false; 101 | } 102 | 103 | $sourceRatio = $sourceWidth / $sourceHeight; 104 | $targetRatio = $maxWidth / $maxHeight; 105 | 106 | if ($sourceRatio > $targetRatio) { 107 | $scaledHeight = $maxHeight; 108 | $scaledWidth = (int)($sourceWidth * ($scaledHeight / $sourceHeight)); 109 | $sourceX = (int)(($sourceWidth - ($sourceHeight * $targetRatio)) / 2); 110 | $sourceY = 0; 111 | $sourceUseWidth = (int)($sourceHeight * $targetRatio); 112 | $sourceUseHeight = $sourceHeight; 113 | } else { 114 | $scaledWidth = $maxWidth; 115 | $scaledHeight = (int)($sourceHeight * ($scaledWidth / $sourceWidth)); 116 | $sourceX = 0; 117 | $sourceY = (int)(($sourceHeight - ($sourceWidth / $targetRatio)) / 2); 118 | $sourceUseWidth = $sourceWidth; 119 | $sourceUseHeight = (int)($sourceWidth / $targetRatio); 120 | } 121 | 122 | $targetImage = imagecreatetruecolor($maxWidth, $maxHeight); 123 | if (!$targetImage) { 124 | imagedestroy($sourceImage); 125 | return false; 126 | } 127 | 128 | if ($mimeType === 'image/png') { 129 | imagealphablending($targetImage, false); 130 | imagesavealpha($targetImage, true); 131 | $transparent = imagecolorallocatealpha($targetImage, 255, 255, 255, 127); 132 | imagefilledrectangle($targetImage, 0, 0, $maxWidth, $maxHeight, $transparent); 133 | } 134 | 135 | imagecopyresampled( 136 | $targetImage, 137 | $sourceImage, 138 | 0, 0, $sourceX, $sourceY, 139 | $maxWidth, $maxHeight, 140 | $sourceUseWidth, $sourceUseHeight 141 | ); 142 | 143 | $result = $this->saveImage($targetImage, $destPath, 'image/jpeg', 90); 144 | 145 | imagedestroy($sourceImage); 146 | imagedestroy($targetImage); 147 | 148 | return $result; 149 | } 150 | 151 | private function createImageFromFile(string $filePath, string $mimeType): GdImage|false 152 | { 153 | switch ($mimeType) { 154 | case 'image/jpeg': 155 | return imagecreatefromjpeg($filePath); 156 | case 'image/png': 157 | return imagecreatefrompng($filePath); 158 | case 'image/gif': 159 | return imagecreatefromgif($filePath); 160 | case 'image/webp': 161 | return imagecreatefromwebp($filePath); 162 | case 'image/bmp': 163 | return imagecreatefrombmp($filePath); 164 | default: 165 | return false; 166 | } 167 | } 168 | 169 | private function saveImage(GdImage $image, string $filePath, string $mimeType, int $quality = 90): bool 170 | { 171 | switch ($mimeType) { 172 | case 'image/jpeg': 173 | return imagejpeg($image, $filePath, $quality); 174 | case 'image/png': 175 | $pngQuality = (int)(9 - (($quality / 100) * 9)); 176 | return imagepng($image, $filePath, $pngQuality); 177 | case 'image/gif': 178 | return imagegif($image, $filePath); 179 | case 'image/webp': 180 | return imagewebp($image, $filePath, $quality); 181 | default: 182 | return imagejpeg($image, $filePath, $quality); 183 | } 184 | } 185 | } -------------------------------------------------------------------------------- /app/templates/feed-builder.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => 'Construtor de Feed']) ?> 2 | 3 | start('active') ?>feed-builderstop() ?> 4 | 5 |
    6 |
    7 |

    8 | 9 | 10 |

    11 |
    12 |
    13 |
    14 |
    15 |
    16 | 17 | 18 |
    19 |
    20 | 21 | 28 | 29 |
    30 |
    31 | 32 |
    33 |
    34 | 35 | 36 |
    37 |
    38 | 39 | 46 | 47 |
    48 |
    49 |
    50 | 51 |
    52 |
    53 | 57 |
    58 | 59 | 62 | 63 | 64 | 65 |
    66 |
    67 | 68 |
    69 | 73 |
    74 | 75 | 78 | 79 | 80 | 81 |
    82 |
    83 |
    84 | 85 |
    86 |
    87 | 88 | start('scripts') ?> 89 | 160 | stop() ?> -------------------------------------------------------------------------------- /app/setup/migration.php: -------------------------------------------------------------------------------- 1 | quote($tableName); 23 | $escapedTableName = substr($escapedTableName, 1, -1); 24 | 25 | $result = $conn->query("SHOW TABLES LIKE '{$escapedTableName}'"); 26 | return $result->rowCount() > 0; 27 | } 28 | 29 | function migrationExists($conn, $migrationName) { 30 | try { 31 | $stmt = $conn->prepare("SELECT id FROM migrations WHERE migration = :migration"); 32 | $stmt->execute(['migration' => $migrationName]); 33 | return $stmt->rowCount() > 0; 34 | } catch (PDOException $e) { 35 | return false; 36 | } 37 | } 38 | 39 | function registerMigration($conn, $migrationName) { 40 | try { 41 | $stmt = $conn->prepare("INSERT INTO migrations (migration) VALUES (:migration)"); 42 | $stmt->execute(['migration' => $migrationName]); 43 | return true; 44 | } catch (PDOException $e) { 45 | echo "Error registering migration: " . $e->getMessage() . PHP_EOL; 46 | return false; 47 | } 48 | } 49 | 50 | function executeSqlFile($conn, $filePath, $migrationName = null) { 51 | $sql = file_get_contents($filePath); 52 | 53 | $queries = []; 54 | $currentQuery = ''; 55 | $inTrigger = false; 56 | 57 | $lines = explode("\n", $sql); 58 | foreach ($lines as $line) { 59 | $trimmedLine = trim($line); 60 | 61 | if (empty($trimmedLine) || strpos($trimmedLine, '--') === 0) { 62 | continue; 63 | } 64 | 65 | if (preg_match('/CREATE\s+(TRIGGER|PROCEDURE|FUNCTION)/i', $trimmedLine)) { 66 | $inTrigger = true; 67 | } 68 | 69 | $currentQuery .= $line . "\n"; 70 | 71 | if ($inTrigger) { 72 | if (preg_match('/END\s*;/i', $trimmedLine)) { 73 | $queries[] = trim($currentQuery); 74 | $currentQuery = ''; 75 | $inTrigger = false; 76 | } 77 | } else { 78 | if (substr($trimmedLine, -1) === ';') { 79 | $queries[] = trim($currentQuery); 80 | $currentQuery = ''; 81 | } 82 | } 83 | } 84 | 85 | if (!empty(trim($currentQuery))) { 86 | $queries[] = trim($currentQuery); 87 | } 88 | 89 | try { 90 | $containsDDL = false; 91 | foreach ($queries as $query) { 92 | if (!empty($query)) { 93 | $upperQuery = strtoupper($query); 94 | if (preg_match('/^\s*(ALTER|CREATE|DROP)\s+/i', $upperQuery)) { 95 | $containsDDL = true; 96 | break; 97 | } 98 | } 99 | } 100 | 101 | if (!$containsDDL) { 102 | $conn->beginTransaction(); 103 | } 104 | 105 | foreach ($queries as $index => $query) { 106 | if (!empty($query)) { 107 | try { 108 | $conn->exec($query); 109 | } catch (PDOException $queryEx) { 110 | echo "Query #" . ($index + 1) . " failed: " . substr($query, 0, 100) . "..." . PHP_EOL; 111 | throw $queryEx; 112 | } 113 | } 114 | } 115 | 116 | if (!$containsDDL) { 117 | $conn->commit(); 118 | } 119 | 120 | if ($migrationName !== null && tableExists($conn, 'migrations')) { 121 | registerMigration($conn, $migrationName); 122 | } 123 | 124 | return true; 125 | } catch (PDOException $e) { 126 | if (!$containsDDL) { 127 | try { 128 | $conn->rollBack(); 129 | } catch (PDOException $rollbackEx) { 130 | // Ignore rollback errors 131 | } 132 | } 133 | echo "Error executing query: " . $e->getMessage() . PHP_EOL; 134 | return false; 135 | } 136 | } 137 | 138 | try { 139 | // Check if ENV_SOURCE is provided as command-line argument 140 | $envFile = '../.env'; 141 | if (isset($argv[1]) && strpos($argv[1], 'ENV_SOURCE=') === 0) { 142 | $envFile = substr($argv[1], strlen('ENV_SOURCE=')); 143 | } 144 | 145 | $env = parseEnvFile($envFile); 146 | 147 | if (empty($env)) { 148 | throw new Exception("Failed to parse .env file or file is empty"); 149 | } 150 | 151 | $dbHost = $env['LERAMA_DB_HOST'] ?? 'localhost'; 152 | $dbName = $env['LERAMA_DB_NAME'] ?? ''; 153 | $dbUser = $env['LERAMA_DB_USER'] ?? ''; 154 | $dbPass = $env['LERAMA_DB_PASS'] ?? ''; 155 | $dbPort = $env['LERAMA_DB_PORT'] ?? 3306; 156 | 157 | if (empty($dbName) || empty($dbUser)) { 158 | throw new Exception("Database configuration is incomplete in .env file"); 159 | } 160 | 161 | $dsn = "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4"; 162 | $options = [ 163 | PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 164 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 165 | PDO::ATTR_EMULATE_PREPARES => false, 166 | ]; 167 | 168 | try { 169 | $conn = new PDO($dsn, $dbUser, $dbPass, $options); 170 | } catch (PDOException $e) { 171 | throw new Exception("Connection failed: " . $e->getMessage()); 172 | } 173 | 174 | echo "Connected to database successfully" . PHP_EOL; 175 | 176 | $stmt = $conn->query("SHOW TABLES"); 177 | $tables = $stmt->fetchAll(); 178 | $hasAnyTables = count($tables) > 0; 179 | 180 | if (!$hasAnyTables) { 181 | echo "No migrations registered. Running initial.sql..." . PHP_EOL; 182 | 183 | $initialFile = __DIR__ . '/initial.sql'; 184 | if (!file_exists($initialFile)) { 185 | throw new Exception("Initial schema file not found: {$initialFile}"); 186 | } 187 | 188 | if (executeSqlFile($conn, $initialFile)) { 189 | echo "Initial schema executed successfully" . PHP_EOL; 190 | } else { 191 | throw new Exception("Failed to execute initial schema"); 192 | } 193 | } 194 | 195 | $migrationFiles = glob(__DIR__ . '/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].sql'); 196 | 197 | if (empty($migrationFiles)) { 198 | echo "No migration files found" . PHP_EOL; 199 | } else { 200 | sort($migrationFiles); 201 | 202 | echo "Found " . count($migrationFiles) . " migration file(s)" . PHP_EOL; 203 | 204 | foreach ($migrationFiles as $filePath) { 205 | $fileName = basename($filePath); 206 | $migrationKey = pathinfo($fileName, PATHINFO_FILENAME); 207 | 208 | if (!migrationExists($conn, $migrationKey)) { 209 | echo "Running migration: {$migrationKey}..." . PHP_EOL; 210 | 211 | if (executeSqlFile($conn, $filePath, $migrationKey)) { 212 | echo "Migration {$migrationKey} executed successfully" . PHP_EOL; 213 | } else { 214 | throw new Exception("Failed to execute migration: {$migrationKey}"); 215 | } 216 | } else { 217 | echo "Migration {$migrationKey} already applied" . PHP_EOL; 218 | } 219 | } 220 | } 221 | 222 | echo "Database migration completed successfully" . PHP_EOL; 223 | } catch (Exception $e) { 224 | echo "Error: " . $e->getMessage() . PHP_EOL; 225 | exit(1); 226 | } -------------------------------------------------------------------------------- /app/lang/en.php: -------------------------------------------------------------------------------- 1 | 'Home', 6 | 'nav.feeds' => 'Feeds', 7 | 'nav.suggest' => 'Suggest', 8 | 'nav.articles' => 'Articles', 9 | 'nav.categories' => 'Categories', 10 | 'nav.tags' => 'Tags', 11 | 'nav.topics' => 'Topics', 12 | 'nav.logout' => 'Logout', 13 | 'nav.feed_builder' => 'Feed Builder', 14 | 15 | // Common 16 | 'common.search' => 'Search', 17 | 'common.filter' => 'Filter', 18 | 'common.search_placeholder' => 'Search...', 19 | 'common.all_categories' => 'All Categories', 20 | 'common.all_tags' => 'All Tags', 21 | 'common.all_topics' => 'All Topics', 22 | 'common.simplified' => 'Simplified', 23 | 'common.save_filter' => 'Save Filter', 24 | 'common.clear_filters' => 'Clear Filters', 25 | 'common.cancel' => 'Cancel', 26 | 'common.save' => 'Save', 27 | 'common.delete' => 'Delete', 28 | 'common.edit' => 'Edit', 29 | 'common.add' => 'Add', 30 | 'common.update' => 'Update', 31 | 'common.name' => 'Name', 32 | 'common.slug' => 'Slug', 33 | 'common.description' => 'Description', 34 | 'common.language' => 'Language', 35 | 'common.status' => 'Status', 36 | 'common.actions' => 'Actions', 37 | 'common.in' => 'in', 38 | 'common.at' => 'at', 39 | 40 | // Languages 41 | 'lang.pt-BR' => 'Portuguese (Brazil)', 42 | 'lang.en' => 'English', 43 | 'lang.es' => 'Spanish', 44 | 45 | // Status 46 | 'status.online' => 'Online', 47 | 'status.offline' => 'Offline', 48 | 'status.paused' => 'Paused', 49 | 'status.pending' => 'Pending', 50 | 'status.rejected' => 'Rejected', 51 | 52 | // Home page 53 | 'home.title' => 'Latest articles', 54 | 'home.no_items' => 'No items found. Try adjusting your search or filter.', 55 | 'home.published_at' => 'Published on', 56 | 'home.author' => 'Author', 57 | 58 | // Feeds page 59 | 'feeds.title' => 'Feeds', 60 | 'feeds.feed' => 'Feed', 61 | 'feeds.categories' => 'Categories', 62 | 'feeds.tags' => 'Tags', 63 | 'feeds.topics' => 'Topics', 64 | 'feeds.verification' => 'Verification', 65 | 'feeds.update' => 'Update', 66 | 'feeds.articles' => 'Articles', 67 | 'feeds.items' => 'Items', 68 | 'feeds.no_feeds' => 'No feeds found.', 69 | 'feeds.verified' => 'Verif', 70 | 'feeds.updated' => 'Updated', 71 | 'feeds.never' => 'Never', 72 | 73 | // Suggest Feed page 74 | 'suggest.title' => 'Suggest', 75 | 'suggest.heading' => 'Suggest Feed', 76 | 'suggest.description' => 'Know an interesting blog that should be in our aggregator? Suggest it here!', 77 | 'suggest.form.title' => 'Title', 78 | 'suggest.form.title_placeholder' => 'E.g.: John\'s Blog', 79 | 'suggest.form.site_url' => 'Site URL', 80 | 'suggest.form.site_url_help' => 'The main URL of the site/blog', 81 | 'suggest.form.feed_url' => 'Feed URL (RSS/Atom)', 82 | 'suggest.form.feed_url_help' => 'The URL of the blog\'s RSS/Atom file', 83 | 'suggest.form.category' => 'Category', 84 | 'suggest.form.tags' => 'Topics', 85 | 'suggest.form.select_tag' => 'Select Topics', 86 | 'suggest.form.captcha' => 'Verification Code', 87 | 'suggest.form.captcha_placeholder' => 'Enter the code shown', 88 | 'suggest.form.captcha_help' => 'Click the image to generate a new code', 89 | 'suggest.form.submit' => 'Send Suggestion', 90 | 'suggest.form.validating' => 'Validating feed...', 91 | 92 | // Feed Builder page 93 | 'feed_builder.title' => 'Feed Builder', 94 | 'feed_builder.categories' => 'Categories', 95 | 'feed_builder.tags' => 'Tags', 96 | 'feed_builder.topics' => 'Topics', 97 | 'feed_builder.rss_feed' => 'RSS Feed', 98 | 'feed_builder.json_feed' => 'JSON Feed', 99 | 100 | // Categories page 101 | 'categories.title' => 'Categories', 102 | 'categories.no_categories' => 'No categories found.', 103 | 'categories.article' => 'article', 104 | 'categories.articles' => 'articles', 105 | 106 | // Tags page 107 | 'tags.title' => 'Topics', 108 | 'tags.no_tags' => 'No tags found.', 109 | 'tags.article' => 'article', 110 | 'tags.articles' => 'articles', 111 | 112 | // Admin - Login 113 | 'admin.login.title' => 'Login', 114 | 'admin.login.username' => 'Username', 115 | 'admin.login.password' => 'Password', 116 | 'admin.login.submit' => 'Sign In', 117 | 118 | // Admin - Items 119 | 'admin.items.title' => 'Manage Articles', 120 | 'admin.items.feed' => 'Feed', 121 | 'admin.items.feeds' => 'Feeds', 122 | 'admin.items.author' => 'Author', 123 | 'admin.items.published' => 'Published', 124 | 'admin.items.unknown_author' => 'Unknown', 125 | 'admin.items.no_items' => 'No items found. Try adjusting your search or filter.', 126 | 127 | // Admin - Feeds 128 | 'admin.feeds.title' => 'Manage Feeds', 129 | 'admin.feeds.add_new' => 'Add New Feed', 130 | 'admin.feeds.filter_status' => 'Filter by Status', 131 | 'admin.feeds.all_status' => 'All Statuses', 132 | 'admin.feeds.bulk_status' => 'Change Status', 133 | 'admin.feeds.bulk_categories' => 'Edit Categories', 134 | 'admin.feeds.bulk_tags' => 'Edit Tags', 135 | 'admin.feeds.selected' => 'selected', 136 | 'admin.feeds.no_feeds' => 'No feeds found.', 137 | 'admin.feeds.try_filter' => 'Try another filter or ', 138 | 'admin.feeds.add_first' => 'Add your first feed using the button above.', 139 | 'admin.feeds.delete_confirm' => 'Are you sure you want to delete this feed? All feed items will also be deleted. This action cannot be undone.', 140 | 'admin.feeds.delete_modal_title' => 'Delete Feed', 141 | 'admin.feeds.bulk_categories_modal_title' => 'Bulk Edit Categories', 142 | 'admin.feeds.bulk_categories_description' => 'Select the categories you want to apply to', 143 | 'admin.feeds.bulk_categories_note' => 'selected feed(s). Current categories will be replaced.', 144 | 'admin.feeds.apply_categories' => 'Apply Categories', 145 | 'admin.feeds.bulk_tags_modal_title' => 'Bulk Edit Topics', 146 | 'admin.feeds.bulk_tags_description' => 'Select the topics you want to apply to', 147 | 'admin.feeds.bulk_tags_note' => 'selected feed(s). Current topics will be replaced.', 148 | 'admin.feeds.apply_tags' => 'Apply Tags', 149 | 'admin.feeds.status_updated' => 'Feed status successfully updated to:', 150 | 151 | // Admin - Feed Form 152 | 'admin.feed_form.edit_title' => 'Edit Feed', 153 | 'admin.feed_form.add_title' => 'Add New Feed', 154 | 'admin.feed_form.site_title' => 'Site Title', 155 | 'admin.feed_form.feed_url' => 'Feed URL', 156 | 'admin.feed_form.site_url' => 'Site URL', 157 | 'admin.feed_form.feed_type' => 'Feed Type', 158 | 'admin.feed_form.auto_detect' => 'Auto-detect feed type', 159 | 'admin.feed_form.feed_type_help' => 'If not selected, the system will automatically detect the feed type.', 160 | 'admin.feed_form.categories' => 'Categories', 161 | 'admin.feed_form.categories_help' => 'Hold Ctrl/Cmd to select multiple categories', 162 | 'admin.feed_form.tags' => 'Topics', 163 | 'admin.feed_form.tags_help' => 'Hold Ctrl/Cmd to select multiple tags', 164 | 'admin.feed_form.update' => 'Update Feed', 165 | 'admin.feed_form.add' => 'Add Feed', 166 | 'admin.feed_form.saving' => 'Saving...', 167 | 168 | // Admin - Categories 169 | 'admin.categories.title' => 'Manage Categories', 170 | 'admin.categories.new' => 'New Category', 171 | 'admin.categories.no_categories' => 'No categories registered', 172 | 'admin.categories.feeds' => 'feeds', 173 | 'admin.categories.delete_confirm' => 'Are you sure you want to delete this category?', 174 | 175 | // Admin - Category Form 176 | 'admin.category_form.name' => 'Name', 177 | 'admin.category_form.slug' => 'Slug', 178 | 'admin.category_form.slug_help' => 'Leave blank to auto-generate', 179 | 180 | // Admin - Tags 181 | 'admin.tags.title' => 'Manage Tags', 182 | 'admin.tags.new' => 'New Tag', 183 | 'admin.tags.no_tags' => 'No tags registered', 184 | 'admin.tags.feeds' => 'feeds', 185 | 'admin.tags.delete_confirm' => 'Are you sure you want to delete this tag?', 186 | 187 | // Footer 188 | 'footer.description' => 'Directory and search engine for personal blogs updated in real time.', 189 | 'footer.badge' => 'Badge', 190 | 'footer.copied' => 'Copied!', 191 | 'footer.copy_error' => 'Could not copy the code. Please try again.', 192 | 193 | // Meta 194 | 'meta.description' => 'Directory of personal blogs based on RSS 1.0/2.0, Atom, JSON, XML feeds.', 195 | 196 | // Alerts and Messages 197 | 'alert.error' => 'Error', 198 | 'alert.success' => 'Success', 199 | 'alert.warning' => 'Warning', 200 | 'alert.info' => 'Information', 201 | 202 | // Pagination 203 | 'pagination.previous' => 'Previous', 204 | 'pagination.next' => 'Next', 205 | ]; -------------------------------------------------------------------------------- /app/lang/pt-BR.php: -------------------------------------------------------------------------------- 1 | 'Início', 6 | 'nav.feeds' => 'Feeds', 7 | 'nav.suggest' => 'Sugerir', 8 | 'nav.articles' => 'Artigos', 9 | 'nav.categories' => 'Categorias', 10 | 'nav.tags' => 'Tópicos', 11 | 'nav.topics' => 'Tópicos', 12 | 'nav.logout' => 'Sair', 13 | 'nav.feed_builder' => 'Construtor de Feed', 14 | 15 | // Common 16 | 'common.search' => 'Buscar', 17 | 'common.filter' => 'Filtrar', 18 | 'common.search_placeholder' => 'Pesquisar...', 19 | 'common.all_categories' => 'Todas Categorias', 20 | 'common.all_tags' => 'Todos Tópicos', 21 | 'common.all_topics' => 'Todos Tópicos', 22 | 'common.simplified' => 'Simplificado', 23 | 'common.save_filter' => 'Salvar Filtro', 24 | 'common.clear_filters' => 'Limpar Filtros', 25 | 'common.cancel' => 'Cancelar', 26 | 'common.save' => 'Salvar', 27 | 'common.delete' => 'Excluir', 28 | 'common.edit' => 'Editar', 29 | 'common.add' => 'Adicionar', 30 | 'common.update' => 'Atualizar', 31 | 'common.name' => 'Nome', 32 | 'common.slug' => 'Slug', 33 | 'common.description' => 'Descrição', 34 | 'common.language' => 'Idioma', 35 | 'common.status' => 'Status', 36 | 'common.actions' => 'Ações', 37 | 'common.in' => 'em', 38 | 'common.at' => 'às', 39 | 40 | // Languages 41 | 'lang.pt-BR' => 'Português (Brasil)', 42 | 'lang.en' => 'Inglês', 43 | 'lang.es' => 'Espanhol', 44 | 45 | // Status 46 | 'status.online' => 'Online', 47 | 'status.offline' => 'Offline', 48 | 'status.paused' => 'Pausado', 49 | 'status.pending' => 'Pendente', 50 | 'status.rejected' => 'Rejeitado', 51 | 52 | // Home page 53 | 'home.title' => 'Últimos artigos', 54 | 'home.no_items' => 'Nenhum item encontrado. Tente ajustar sua pesquisa ou filtro.', 55 | 'home.published_at' => 'Publicado em', 56 | 'home.author' => 'Autor', 57 | 58 | // Feeds page 59 | 'feeds.title' => 'Feeds', 60 | 'feeds.feed' => 'Feed', 61 | 'feeds.categories' => 'Categorias', 62 | 'feeds.tags' => 'Tags', 63 | 'feeds.topics' => 'Tópicos', 64 | 'feeds.verification' => 'Verificação', 65 | 'feeds.update' => 'Atualização', 66 | 'feeds.articles' => 'Artigos', 67 | 'feeds.items' => 'Itens', 68 | 'feeds.no_feeds' => 'Nenhum feed encontrado.', 69 | 'feeds.verified' => 'Verif', 70 | 'feeds.updated' => 'Atual', 71 | 'feeds.never' => 'Nunca', 72 | 73 | // Suggest Feed page 74 | 'suggest.title' => 'Sugerir', 75 | 'suggest.heading' => 'Sugerir Feed', 76 | 'suggest.description' => 'Conhece um blog interessante que deveria estar no nosso agregador? Sugira aqui!', 77 | 'suggest.form.title' => 'Título', 78 | 'suggest.form.title_placeholder' => 'Ex: Blog do João', 79 | 'suggest.form.site_url' => 'URL do Site', 80 | 'suggest.form.site_url_help' => 'A URL principal do site/blog', 81 | 'suggest.form.feed_url' => 'URL do Feed (RSS/Atom)', 82 | 'suggest.form.feed_url_help' => 'A URL do arquivo RSS/Atom do blog', 83 | 'suggest.form.category' => 'Categoria', 84 | 'suggest.form.tags' => 'Tópicos', 85 | 'suggest.form.select_tag' => 'Selecione Tópico', 86 | 'suggest.form.captcha' => 'Código de Verificação', 87 | 'suggest.form.captcha_placeholder' => 'Digite o código ao lado', 88 | 'suggest.form.captcha_help' => 'Clique na imagem para gerar um novo código', 89 | 'suggest.form.submit' => 'Enviar Sugestão', 90 | 'suggest.form.validating' => 'Validando feed...', 91 | 92 | // Feed Builder page 93 | 'feed_builder.title' => 'Construtor de Feed', 94 | 'feed_builder.categories' => 'Categorias', 95 | 'feed_builder.tags' => 'Tópicos', 96 | 'feed_builder.topics' => 'Tópicos', 97 | 'feed_builder.rss_feed' => 'Feed RSS', 98 | 'feed_builder.json_feed' => 'Feed JSON', 99 | 100 | // Categories page 101 | 'categories.title' => 'Categorias', 102 | 'categories.no_categories' => 'Nenhuma categoria encontrada.', 103 | 'categories.article' => 'artigo', 104 | 'categories.articles' => 'artigos', 105 | 106 | // Tags page 107 | 'tags.title' => 'Tópicos', 108 | 'tags.no_tags' => 'Nenhuma tag encontrada.', 109 | 'tags.article' => 'artigo', 110 | 'tags.articles' => 'artigos', 111 | 112 | // Admin - Login 113 | 'admin.login.title' => 'Login', 114 | 'admin.login.username' => 'Nome de usuário', 115 | 'admin.login.password' => 'Senha', 116 | 'admin.login.submit' => 'Entrar', 117 | 118 | // Admin - Items 119 | 'admin.items.title' => 'Gerenciar Artigos', 120 | 'admin.items.feed' => 'Feed', 121 | 'admin.items.feeds' => 'Feeds', 122 | 'admin.items.author' => 'Autor', 123 | 'admin.items.published' => 'Publicado', 124 | 'admin.items.unknown_author' => 'Desconhecido', 125 | 'admin.items.no_items' => 'Nenhum item encontrado. Tente ajustar sua pesquisa ou filtro.', 126 | 127 | // Admin - Feeds 128 | 'admin.feeds.title' => 'Gerenciar Feeds', 129 | 'admin.feeds.add_new' => 'Adicionar Novo Feed', 130 | 'admin.feeds.filter_status' => 'Filtrar por Status', 131 | 'admin.feeds.all_status' => 'Todos os Status', 132 | 'admin.feeds.bulk_status' => 'Alterar Status', 133 | 'admin.feeds.bulk_categories' => 'Editar Categorias', 134 | 'admin.feeds.bulk_tags' => 'Editar Tags', 135 | 'admin.feeds.selected' => 'selecionado(s)', 136 | 'admin.feeds.no_feeds' => 'Nenhum feed encontrado.', 137 | 'admin.feeds.try_filter' => 'Tente outro filtro ou ', 138 | 'admin.feeds.add_first' => 'Adicione seu primeiro feed usando o botão acima.', 139 | 'admin.feeds.delete_confirm' => 'Tem certeza que deseja excluir este feed? Todos os itens do feed também serão excluídos. Esta ação não pode ser desfeita.', 140 | 'admin.feeds.delete_modal_title' => 'Excluir Feed', 141 | 'admin.feeds.bulk_categories_modal_title' => 'Editar Categorias em Lote', 142 | 'admin.feeds.bulk_categories_description' => 'Selecione as categorias que deseja aplicar aos', 143 | 'admin.feeds.bulk_categories_note' => 'feed(s) selecionado(s). As categorias atuais serão substituídas.', 144 | 'admin.feeds.apply_categories' => 'Aplicar Categorias', 145 | 'admin.feeds.bulk_tags_modal_title' => 'Editar Tópicos em Lote', 146 | 'admin.feeds.bulk_tags_description' => 'Selecione as tópicos que deseja aplicar aos', 147 | 'admin.feeds.bulk_tags_note' => 'feed(s) selecionado(s). Os tópicos atuais serão substituídas.', 148 | 'admin.feeds.apply_tags' => 'Aplicar Tags', 149 | 'admin.feeds.status_updated' => 'Status do feed atualizado com sucesso para:', 150 | 151 | // Admin - Feed Form 152 | 'admin.feed_form.edit_title' => 'Editar Feed', 153 | 'admin.feed_form.add_title' => 'Adicionar Novo Feed', 154 | 'admin.feed_form.site_title' => 'Título do Site', 155 | 'admin.feed_form.feed_url' => 'URL do Feed', 156 | 'admin.feed_form.site_url' => 'URL do Site', 157 | 'admin.feed_form.feed_type' => 'Tipo de Feed', 158 | 'admin.feed_form.auto_detect' => 'Auto-detectar tipo de feed', 159 | 'admin.feed_form.feed_type_help' => 'Se não for selecionado, o sistema detectará automaticamente o tipo de feed.', 160 | 'admin.feed_form.categories' => 'Categorias', 161 | 'admin.feed_form.categories_help' => 'Mantenha Ctrl/Cmd pressionado para selecionar múltiplas categorias', 162 | 'admin.feed_form.tags' => 'Tópicos', 163 | 'admin.feed_form.tags_help' => 'Mantenha Ctrl/Cmd pressionado para selecionar múltiplas tags', 164 | 'admin.feed_form.update' => 'Atualizar Feed', 165 | 'admin.feed_form.add' => 'Adicionar Feed', 166 | 'admin.feed_form.saving' => 'Salvando...', 167 | 168 | // Admin - Categories 169 | 'admin.categories.title' => 'Gerenciar Categorias', 170 | 'admin.categories.new' => 'Nova Categoria', 171 | 'admin.categories.no_categories' => 'Nenhuma categoria cadastrada', 172 | 'admin.categories.feeds' => 'feeds', 173 | 'admin.categories.delete_confirm' => 'Tem certeza que deseja excluir esta categoria?', 174 | 175 | // Admin - Category Form 176 | 'admin.category_form.name' => 'Nome', 177 | 'admin.category_form.slug' => 'Slug', 178 | 'admin.category_form.slug_help' => 'Deixe em branco para gerar automaticamente', 179 | 180 | // Admin - Tags 181 | 'admin.tags.title' => 'Gerenciar Tags', 182 | 'admin.tags.new' => 'Nova Tag', 183 | 'admin.tags.no_tags' => 'Nenhuma tag cadastrada', 184 | 'admin.tags.feeds' => 'feeds', 185 | 'admin.tags.delete_confirm' => 'Tem certeza que deseja excluir esta tag?', 186 | 187 | // Footer 188 | 'footer.description' => 'Diretório e buscador de blogs pessoais atualizado em tempo real.', 189 | 'footer.badge' => 'Selo', 190 | 'footer.copied' => 'Copiado!', 191 | 'footer.copy_error' => 'Não foi possível copiar o código. Por favor, tente novamente.', 192 | 193 | // Meta 194 | 'meta.description' => 'Diretório de blogs pessoais baseados em feeds RSS 1.0/2.0, Atom, JSON, XML.', 195 | 196 | // Alerts and Messages 197 | 'alert.error' => 'Erro', 198 | 'alert.success' => 'Sucesso', 199 | 'alert.warning' => 'Atenção', 200 | 'alert.info' => 'Informação', 201 | 202 | // Pagination 203 | 'pagination.previous' => 'Anterior', 204 | 'pagination.next' => 'Próximo', 205 | ]; -------------------------------------------------------------------------------- /app/lang/es.php: -------------------------------------------------------------------------------- 1 | 'Inicio', 6 | 'nav.feeds' => 'Feeds', 7 | 'nav.suggest' => 'Sugerir', 8 | 'nav.articles' => 'Artículos', 9 | 'nav.categories' => 'Categorías', 10 | 'nav.tags' => 'Etiquetas', 11 | 'nav.topics' => 'Temas', 12 | 'nav.logout' => 'Salir', 13 | 'nav.feed_builder' => 'Constructor de Feed', 14 | 15 | // Common 16 | 'common.search' => 'Buscar', 17 | 'common.filter' => 'Filtrar', 18 | 'common.search_placeholder' => 'Buscar...', 19 | 'common.all_categories' => 'Todas las Categorías', 20 | 'common.all_tags' => 'Todas las Etiquetas', 21 | 'common.all_topics' => 'Todos los Temas', 22 | 'common.simplified' => 'Simplificado', 23 | 'common.save_filter' => 'Guardar Filtro', 24 | 'common.clear_filters' => 'Limpiar Filtros', 25 | 'common.cancel' => 'Cancelar', 26 | 'common.save' => 'Guardar', 27 | 'common.delete' => 'Eliminar', 28 | 'common.edit' => 'Editar', 29 | 'common.add' => 'Añadir', 30 | 'common.update' => 'Actualizar', 31 | 'common.name' => 'Nombre', 32 | 'common.slug' => 'Slug', 33 | 'common.description' => 'Descripción', 34 | 'common.language' => 'Idioma', 35 | 'common.status' => 'Estado', 36 | 'common.actions' => 'Acciones', 37 | 'common.in' => 'en', 38 | 'common.at' => 'a las', 39 | 40 | // Languages 41 | 'lang.pt-BR' => 'Portugués (Brasil)', 42 | 'lang.en' => 'Inglés', 43 | 'lang.es' => 'Español', 44 | 45 | // Status 46 | 'status.online' => 'En línea', 47 | 'status.offline' => 'Fuera de línea', 48 | 'status.paused' => 'Pausado', 49 | 'status.pending' => 'Pendiente', 50 | 'status.rejected' => 'Rechazado', 51 | 52 | // Home page 53 | 'home.title' => 'Últimos artículos', 54 | 'home.no_items' => 'No se encontraron elementos. Intente ajustar su búsqueda o filtro.', 55 | 'home.published_at' => 'Publicado en', 56 | 'home.author' => 'Autor', 57 | 58 | // Feeds page 59 | 'feeds.title' => 'Feeds', 60 | 'feeds.feed' => 'Feed', 61 | 'feeds.categories' => 'Categorías', 62 | 'feeds.tags' => 'Etiquetas', 63 | 'feeds.topics' => 'Temas', 64 | 'feeds.verification' => 'Verificación', 65 | 'feeds.update' => 'Actualización', 66 | 'feeds.articles' => 'Artículos', 67 | 'feeds.items' => 'Elementos', 68 | 'feeds.no_feeds' => 'No se encontraron feeds.', 69 | 'feeds.verified' => 'Verif', 70 | 'feeds.updated' => 'Actual', 71 | 'feeds.never' => 'Nunca', 72 | 73 | // Suggest Feed page 74 | 'suggest.title' => 'Sugerir', 75 | 'suggest.heading' => 'Sugerir Feed', 76 | 'suggest.description' => '¿Conoces un blog interesante que debería estar en nuestro agregador? ¡Sugiérelo aquí!', 77 | 'suggest.form.title' => 'Título', 78 | 'suggest.form.title_placeholder' => 'Ej: Blog de Juan', 79 | 'suggest.form.site_url' => 'URL del Sitio', 80 | 'suggest.form.site_url_help' => 'La URL principal del sitio/blog', 81 | 'suggest.form.feed_url' => 'URL del Feed (RSS/Atom)', 82 | 'suggest.form.feed_url_help' => 'La URL del archivo RSS/Atom del blog', 83 | 'suggest.form.category' => 'Categoría', 84 | 'suggest.form.tags' => 'Temas', 85 | 'suggest.form.select_tag' => 'Seleccione Temas', 86 | 'suggest.form.captcha' => 'Código de Verificación', 87 | 'suggest.form.captcha_placeholder' => 'Ingrese el código mostrado', 88 | 'suggest.form.captcha_help' => 'Haga clic en la imagen para generar un nuevo código', 89 | 'suggest.form.submit' => 'Enviar Sugerencia', 90 | 'suggest.form.validating' => 'Validando feed...', 91 | 92 | // Feed Builder page 93 | 'feed_builder.title' => 'Constructor de Feed', 94 | 'feed_builder.categories' => 'Categorías', 95 | 'feed_builder.tags' => 'Etiquetas', 96 | 'feed_builder.topics' => 'Temas', 97 | 'feed_builder.rss_feed' => 'Feed RSS', 98 | 'feed_builder.json_feed' => 'Feed JSON', 99 | 100 | // Categories page 101 | 'categories.title' => 'Categorías', 102 | 'categories.no_categories' => 'No se encontraron categorías.', 103 | 'categories.article' => 'artículo', 104 | 'categories.articles' => 'artículos', 105 | 106 | // Tags page 107 | 'tags.title' => 'Temas', 108 | 'tags.no_tags' => 'No se encontraron etiquetas.', 109 | 'tags.article' => 'artículo', 110 | 'tags.articles' => 'artículos', 111 | 112 | // Admin - Login 113 | 'admin.login.title' => 'Iniciar Sesión', 114 | 'admin.login.username' => 'Nombre de usuario', 115 | 'admin.login.password' => 'Contraseña', 116 | 'admin.login.submit' => 'Entrar', 117 | 118 | // Admin - Items 119 | 'admin.items.title' => 'Administrar Artículos', 120 | 'admin.items.feed' => 'Feed', 121 | 'admin.items.feeds' => 'Feeds', 122 | 'admin.items.author' => 'Autor', 123 | 'admin.items.published' => 'Publicado', 124 | 'admin.items.unknown_author' => 'Desconocido', 125 | 'admin.items.no_items' => 'No se encontraron elementos. Intente ajustar su búsqueda o filtro.', 126 | 127 | // Admin - Feeds 128 | 'admin.feeds.title' => 'Administrar Feeds', 129 | 'admin.feeds.add_new' => 'Añadir Nuevo Feed', 130 | 'admin.feeds.filter_status' => 'Filtrar por Estado', 131 | 'admin.feeds.all_status' => 'Todos los Estados', 132 | 'admin.feeds.bulk_status' => 'Cambiar Estado', 133 | 'admin.feeds.bulk_categories' => 'Editar Categorías', 134 | 'admin.feeds.bulk_tags' => 'Editar Etiquetas', 135 | 'admin.feeds.selected' => 'seleccionado(s)', 136 | 'admin.feeds.no_feeds' => 'No se encontraron feeds.', 137 | 'admin.feeds.try_filter' => 'Pruebe otro filtro o ', 138 | 'admin.feeds.add_first' => 'Añada su primer feed usando el botón de arriba.', 139 | 'admin.feeds.delete_confirm' => '¿Está seguro de que desea eliminar este feed? Todos los elementos del feed también serán eliminados. Esta acción no se puede deshacer.', 140 | 'admin.feeds.delete_modal_title' => 'Eliminar Feed', 141 | 'admin.feeds.bulk_categories_modal_title' => 'Edición Masiva de Categorías', 142 | 'admin.feeds.bulk_categories_description' => 'Seleccione las categorías que desea aplicar a', 143 | 'admin.feeds.bulk_categories_note' => 'feed(s) seleccionado(s). Las categorías actuales serán reemplazadas.', 144 | 'admin.feeds.apply_categories' => 'Aplicar Categorías', 145 | 'admin.feeds.bulk_tags_modal_title' => 'Edición Masiva de Temas', 146 | 'admin.feeds.bulk_tags_description' => 'Seleccione los temas que desea aplicar a', 147 | 'admin.feeds.bulk_tags_note' => 'feed(s) seleccionado(s). Los temas actuales serán reemplazados.', 148 | 'admin.feeds.apply_tags' => 'Aplicar Etiquetas', 149 | 'admin.feeds.status_updated' => 'Estado del feed actualizado exitosamente a:', 150 | 151 | // Admin - Feed Form 152 | 'admin.feed_form.edit_title' => 'Editar Feed', 153 | 'admin.feed_form.add_title' => 'Añadir Nuevo Feed', 154 | 'admin.feed_form.site_title' => 'Título del Sitio', 155 | 'admin.feed_form.feed_url' => 'URL del Feed', 156 | 'admin.feed_form.site_url' => 'URL del Sitio', 157 | 'admin.feed_form.feed_type' => 'Tipo de Feed', 158 | 'admin.feed_form.auto_detect' => 'Detectar automáticamente el tipo de feed', 159 | 'admin.feed_form.feed_type_help' => 'Si no se selecciona, el sistema detectará automáticamente el tipo de feed.', 160 | 'admin.feed_form.categories' => 'Categorías', 161 | 'admin.feed_form.categories_help' => 'Mantenga presionado Ctrl/Cmd para seleccionar múltiples categorías', 162 | 'admin.feed_form.tags' => 'Temas', 163 | 'admin.feed_form.tags_help' => 'Mantenga presionado Ctrl/Cmd para seleccionar múltiples etiquetas', 164 | 'admin.feed_form.update' => 'Actualizar Feed', 165 | 'admin.feed_form.add' => 'Añadir Feed', 166 | 'admin.feed_form.saving' => 'Guardando...', 167 | 168 | // Admin - Categories 169 | 'admin.categories.title' => 'Administrar Categorías', 170 | 'admin.categories.new' => 'Nueva Categoría', 171 | 'admin.categories.no_categories' => 'No hay categorías registradas', 172 | 'admin.categories.feeds' => 'feeds', 173 | 'admin.categories.delete_confirm' => '¿Está seguro de que desea eliminar esta categoría?', 174 | 175 | // Admin - Category Form 176 | 'admin.category_form.name' => 'Nombre', 177 | 'admin.category_form.slug' => 'Slug', 178 | 'admin.category_form.slug_help' => 'Deje en blanco para generar automáticamente', 179 | 180 | // Admin - Tags 181 | 'admin.tags.title' => 'Administrar Etiquetas', 182 | 'admin.tags.new' => 'Nueva Etiqueta', 183 | 'admin.tags.no_tags' => 'No hay etiquetas registradas', 184 | 'admin.tags.feeds' => 'feeds', 185 | 'admin.tags.delete_confirm' => '¿Está seguro de que desea eliminar esta etiqueta?', 186 | 187 | // Footer 188 | 'footer.description' => 'Directorio y buscador de blogs personales actualizado en tiempo real.', 189 | 'footer.badge' => 'Insignia', 190 | 'footer.copied' => '¡Copiado!', 191 | 'footer.copy_error' => 'No se pudo copiar el código. Por favor, inténtelo de nuevo.', 192 | 193 | // Meta 194 | 'meta.description' => 'Directorio de blogs personales basados en feeds RSS 1.0/2.0, Atom, JSON, XML.', 195 | 196 | // Alerts and Messages 197 | 'alert.error' => 'Error', 198 | 'alert.success' => 'Éxito', 199 | 'alert.warning' => 'Advertencia', 200 | 'alert.info' => 'Información', 201 | 202 | // Pagination 203 | 'pagination.previous' => 'Anterior', 204 | 'pagination.next' => 'Siguiente', 205 | ]; -------------------------------------------------------------------------------- /app/src/Controllers/SuggestionController.php: -------------------------------------------------------------------------------- 1 | templates = new Engine(__DIR__ . '/../../templates'); 24 | } 25 | 26 | public function suggestForm(ServerRequestInterface $request): ResponseInterface 27 | { 28 | if (session_status() === PHP_SESSION_NONE) { 29 | session_start(); 30 | } 31 | 32 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 33 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 34 | 35 | $html = $this->templates->render('suggest-feed', [ 36 | 'title' => 'Sugerir Blog/Feed', 37 | 'categories' => $categories, 38 | 'tags' => $tags 39 | ]); 40 | 41 | return new HtmlResponse($html); 42 | } 43 | 44 | public function getCaptcha(ServerRequestInterface $request): ResponseInterface 45 | { 46 | if (session_status() === PHP_SESSION_NONE) { 47 | session_start(); 48 | } 49 | 50 | $builder = new CaptchaBuilder(); 51 | $builder->build(); 52 | 53 | $_SESSION['captcha_phrase'] = $builder->getPhrase(); 54 | 55 | header('Content-Type: image/jpeg'); 56 | $builder->output(); 57 | exit; 58 | } 59 | 60 | public function submitSuggestion(ServerRequestInterface $request): ResponseInterface 61 | { 62 | if (session_status() === PHP_SESSION_NONE) { 63 | session_start(); 64 | } 65 | 66 | $params = (array)$request->getParsedBody(); 67 | $title = trim($params['title'] ?? ''); 68 | $feedUrl = trim($params['feed_url'] ?? ''); 69 | $siteUrl = trim($params['site_url'] ?? ''); 70 | $language = trim($params['language'] ?? 'en'); 71 | $email = trim($params['email'] ?? ''); 72 | $captcha = trim($params['captcha'] ?? ''); 73 | $categoryId = !empty($params['category']) ? (int)$params['category'] : null; 74 | $tagIds = $params['tags'] ?? []; 75 | 76 | $errors = []; 77 | 78 | if (empty($captcha)) { 79 | $errors['captcha'] = 'O código de verificação é obrigatório'; 80 | } elseif (!isset($_SESSION['captcha_phrase']) || $captcha !== $_SESSION['captcha_phrase']) { 81 | $errors['captcha'] = 'Código de verificação inválido'; 82 | } 83 | 84 | unset($_SESSION['captcha_phrase']); 85 | 86 | if (empty($title)) { 87 | $errors['title'] = 'O título do site é obrigatório'; 88 | } elseif (strlen($title) < 3) { 89 | $errors['title'] = 'O título deve ter pelo menos 3 caracteres'; 90 | } 91 | 92 | if (empty($feedUrl)) { 93 | $errors['feed_url'] = 'A URL do feed é obrigatória'; 94 | } elseif (!filter_var($feedUrl, FILTER_VALIDATE_URL)) { 95 | $errors['feed_url'] = 'A URL do feed deve ser uma URL válida'; 96 | } else { 97 | $feedValidation = $this->validateFeed($feedUrl); 98 | if (!$feedValidation['valid']) { 99 | $errors['feed_url'] = $feedValidation['error']; 100 | } 101 | } 102 | 103 | if (empty($siteUrl)) { 104 | $errors['site_url'] = 'A URL do blog é obrigatória'; 105 | } elseif (!filter_var($siteUrl, FILTER_VALIDATE_URL)) { 106 | $errors['site_url'] = 'A URL do blog deve ser uma URL válida'; 107 | } 108 | 109 | $validLanguages = ['en', 'pt-BR', 'es']; 110 | if (!in_array($language, $validLanguages)) { 111 | $errors['language'] = 'Idioma inválido'; 112 | } 113 | 114 | if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) { 115 | $errors['email'] = 'Email inválido'; 116 | } 117 | 118 | $availableCategories = DB::query("SELECT * FROM categories ORDER BY name"); 119 | if (!empty($availableCategories) && empty($categoryId)) { 120 | $errors['category'] = 'A categoria é obrigatória'; 121 | } 122 | 123 | $existingFeed = DB::queryFirstRow("SELECT id, status FROM feeds WHERE feed_url = %s", $feedUrl); 124 | if ($existingFeed) { 125 | if ($existingFeed['status'] == 'pending') { 126 | $errors['feed_url'] = 'Este feed já foi sugerido e está aguardando aprovação'; 127 | } else { 128 | $errors['feed_url'] = 'Este feed já está cadastrado'; 129 | } 130 | } 131 | 132 | if (!empty($errors)) { 133 | if ($request->getHeaderLine('Accept') === 'application/json' || 134 | $request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') { 135 | return new JsonResponse([ 136 | 'success' => false, 137 | 'errors' => $errors 138 | ], 400); 139 | } 140 | 141 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 142 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 143 | 144 | $html = $this->templates->render('suggest-feed', [ 145 | 'title' => 'Sugerir Blog/Feed', 146 | 'categories' => $categories, 147 | 'tags' => $tags, 148 | 'errors' => $errors, 149 | 'data' => [ 150 | 'title' => $title, 151 | 'feed_url' => $feedUrl, 152 | 'site_url' => $siteUrl, 153 | 'language' => $language, 154 | 'email' => $email, 155 | 'selected_category' => $categoryId, 156 | 'selected_tags' => $tagIds 157 | ] 158 | ]); 159 | 160 | return new HtmlResponse($html); 161 | } 162 | 163 | try { 164 | // Detect feed type 165 | $feedType = null; 166 | if (isset($feedValidation['type'])) { 167 | $feedType = $feedValidation['type']; 168 | } 169 | 170 | $feedId = DB::insert('feeds', [ 171 | 'title' => $title, 172 | 'feed_url' => $feedUrl, 173 | 'site_url' => $siteUrl, 174 | 'language' => $language, 175 | 'feed_type' => $feedType, 176 | 'submitter_email' => !empty($email) ? $email : null, 177 | 'status' => 'pending' 178 | ]); 179 | 180 | if ($categoryId !== null) { 181 | DB::query("INSERT IGNORE INTO feed_categories (feed_id, category_id) VALUES (%i, %i)", 182 | $feedId, $categoryId); 183 | } 184 | 185 | // Insert tags 186 | if (!empty($tagIds)) { 187 | foreach ($tagIds as $tagId) { 188 | DB::query("INSERT IGNORE INTO feed_tags (feed_id, tag_id) VALUES (%i, %i)", 189 | $feedId, (int)$tagId); 190 | } 191 | } 192 | 193 | if ($request->getHeaderLine('Accept') === 'application/json' || 194 | $request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') { 195 | return new JsonResponse([ 196 | 'success' => true, 197 | 'message' => 'Sugestão enviada com sucesso! Aguarde a aprovação do administrador.' 198 | ]); 199 | } 200 | 201 | $html = $this->templates->render('suggest-feed', [ 202 | 'title' => 'Sugerir Blog/Feed', 203 | 'success' => 'Sugestão enviada com sucesso! Aguarde a aprovação do administrador.' 204 | ]); 205 | 206 | return new HtmlResponse($html); 207 | } catch (\Exception $e) { 208 | if ($request->getHeaderLine('Accept') === 'application/json' || 209 | $request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') { 210 | return new JsonResponse([ 211 | 'success' => false, 212 | 'message' => 'Erro ao enviar sugestão: ' . $e->getMessage() 213 | ], 500); 214 | } 215 | 216 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 217 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 218 | 219 | $html = $this->templates->render('suggest-feed', [ 220 | 'title' => 'Sugerir Blog/Feed', 221 | 'categories' => $categories, 222 | 'tags' => $tags, 223 | 'errors' => ['general' => 'Erro ao enviar sugestão: ' . $e->getMessage()], 224 | 'data' => [ 225 | 'title' => $title, 226 | 'feed_url' => $feedUrl, 227 | 'site_url' => $siteUrl, 228 | 'language' => $language, 229 | 'email' => $email, 230 | 'selected_category' => $categoryId, 231 | 'selected_tags' => $tagIds 232 | ] 233 | ]); 234 | 235 | return new HtmlResponse($html); 236 | } 237 | } 238 | 239 | private function validateFeed(string $feedUrl): array 240 | { 241 | try { 242 | $detector = new FeedTypeDetector(); 243 | $feedType = $detector->detectType($feedUrl); 244 | 245 | if (!$feedType) { 246 | return [ 247 | 'valid' => false, 248 | 'error' => 'Não foi possível validar o feed. Verifique se a URL está correta e se o feed está acessível.' 249 | ]; 250 | } 251 | 252 | return [ 253 | 'valid' => true, 254 | 'type' => $feedType 255 | ]; 256 | } catch (\Exception $e) { 257 | return [ 258 | 'valid' => false, 259 | 'error' => 'Feed inválido ou inacessível: ' . $e->getMessage() 260 | ]; 261 | } 262 | } 263 | } -------------------------------------------------------------------------------- /app/templates/layout.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <?= $this->e(isset($title) ? $title . ' | ' . $_ENV['APP_NAME'] : $_ENV['APP_NAME']) ?> 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 84 | 85 | 86 | 87 |
    88 |
    89 | section('content') ?> 90 |
    91 |
    92 | 116 | 157 | 158 | 181 | 182 | section('scripts', '') ?> 183 | 184 | 185 | -------------------------------------------------------------------------------- /app/templates/admin/items.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>adminstop() ?> 4 | 5 |
    6 |
    7 |
    8 |
    9 |

    10 | 11 | 12 |

    13 |
    14 |
    15 |
    16 |
    17 | 25 |
    26 |
    27 | 28 | 32 |
    33 |
    34 |
    35 |
    36 |
    37 | 38 | 39 |
    40 |

    41 | 42 | 43 |

    44 |
    45 | 46 |
    47 |
    48 | 49 | 50 | 51 | 57 | 63 | 69 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 87 | 90 | 93 | 96 | 108 | 109 | 110 | 111 |
    52 |
    53 | 54 | 55 |
    56 |
    58 |
    59 | 60 | 61 |
    62 |
    64 |
    65 | 66 | 67 |
    68 |
    70 |
    71 | 72 | 73 |
    74 |
    82 | 83 | e($item['title']) ?> 84 | 85 | 86 | 88 | e($item['feed_title']) ?> 89 | 91 | e($item['author'] ?? __('admin.items.unknown_author')) ?> 92 | 94 | 95 | 97 | 107 |
    112 |
    113 |
    114 | 115 | 1): ?> 116 | 160 | 161 | 162 |
    163 | 164 | start('scripts') ?> 165 | 209 | stop() ?> -------------------------------------------------------------------------------- /app/templates/suggest-feed.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>suggest-feedstop() ?> 4 | 5 |
    6 |
    7 |

    8 | 9 | 10 |

    11 |
    12 | 13 |
    14 | 15 | 21 | 22 | 23 | 24 | 30 | 31 | 32 |

    33 | 34 |

    35 | 36 |
    37 |
    38 |
    39 | 42 |
    43 | 44 | 45 | 46 | 50 |
    51 | 52 |
    e($errors['title']) ?>
    53 | 54 |
    55 | 56 |
    57 | 60 |
    61 | 62 | 63 | 64 | 68 |
    69 |
    70 | 71 |
    e($errors['site_url']) ?>
    72 | 73 |
    74 | 75 |
    76 | 79 |
    80 | 81 | 82 | 83 | 87 |
    88 |
    89 | 90 |
    e($errors['feed_url']) ?>
    91 | 92 |
    93 | 94 |
    95 | 98 |
    99 | 100 | 101 | 102 | 107 |
    108 | 109 |
    e($errors['language']) ?>
    110 | 111 |
    112 | 113 | 114 |
    115 | 118 |
    119 | 120 | 121 | 122 | 130 |
    131 | 132 |
    e($errors['category']) ?>
    133 | 134 |
    135 | 136 | 137 | 138 |
    139 | 142 |
    143 | 144 | 145 | 146 | 155 |
    156 | 157 |
    e($errors['tag']) ?>
    158 | 159 |
    160 | 161 | 162 |
    163 | 166 |
    167 |
    168 | 169 | CAPTCHA 170 | 171 | 175 |
    176 |
    177 | 178 |
    179 | 180 |
    e($errors['captcha']) ?>
    181 | 182 |
    183 |
    184 | 185 |
    186 | 190 |
    191 |
    192 |
    193 |
    194 |
    195 | 196 | start('scripts') ?> 197 | 217 | stop() ?> -------------------------------------------------------------------------------- /app/templates/admin/feed-form.php: -------------------------------------------------------------------------------- 1 | layout('layout', ['title' => $title]) ?> 2 | 3 | start('active') ?>admin-feedsstop() ?> 4 | 5 |
    6 |
    7 |

    8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

    16 |
    17 | 18 |
    19 |
    20 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 |
    34 |
    35 | 38 |
    39 | 40 | 41 | 42 | 43 |
    44 | 45 |
    e($errors['title']) ?>
    46 | 47 |
    48 | 49 |
    50 | 53 |
    54 | 55 | 56 | 57 | 58 |
    59 | 60 |
    e($errors['feed_url']) ?>
    61 | 62 |
    63 | 64 |
    65 | 68 |
    69 | 70 | 71 | 72 | 73 |
    74 | 75 |
    e($errors['site_url']) ?>
    76 | 77 |
    78 | 79 |
    80 | 83 |
    84 | 85 | 86 | 87 | 92 |
    93 | 94 |
    e($errors['language']) ?>
    95 | 96 |
    97 | 98 |
    99 | 102 |
    103 | 104 | 105 | 106 | 116 |
    117 |
    118 | 119 |
    e($errors['feed_type']) ?>
    120 | 121 |
    122 | 123 |
    124 | 127 |
    128 | 129 | 130 | 131 | 141 |
    142 |
    143 |
    144 | 145 |
    146 | 149 |
    150 | 151 | 152 | 153 | 163 |
    164 |
    165 |
    166 | 167 | 168 |
    169 | 172 |
    173 | 174 | 175 | 176 | 183 |
    184 |
    185 | 186 | 187 |
    188 | 189 | 190 | 191 | 192 | 201 |
    202 |
    203 |
    204 |
    205 |
    206 | 207 | start('scripts') ?> 208 | 219 | stop() ?> -------------------------------------------------------------------------------- /app/src/Controllers/FeedController.php: -------------------------------------------------------------------------------- 1 | templates = new Engine(__DIR__ . '/../../templates'); 22 | } 23 | 24 | public function index(ServerRequestInterface $request, array $args = []): ResponseInterface 25 | { 26 | $page = isset($args['page']) ? (int)$args['page'] : 1; 27 | if ($page < 1) { 28 | $page = 1; 29 | } 30 | 31 | $perPage = 50; 32 | $offset = ($page - 1) * $perPage; 33 | 34 | $params = $request->getQueryParams(); 35 | $categorySlug = $params['category'] ?? null; 36 | $tagSlug = $params['tag'] ?? null; 37 | 38 | $query = "SELECT f.*, 39 | (SELECT COUNT(*) FROM feed_items WHERE feed_id = f.id) as item_count, 40 | (SELECT MAX(published_at) FROM feed_items WHERE feed_id = f.id) as latest_item_date 41 | FROM feeds f 42 | WHERE 1=1"; 43 | $countQuery = "SELECT COUNT(*) FROM feeds f WHERE 1=1"; 44 | $queryParams = []; 45 | 46 | if ($categorySlug) { 47 | $query .= " AND EXISTS ( 48 | SELECT 1 FROM feed_categories fc 49 | JOIN categories c ON fc.category_id = c.id 50 | WHERE fc.feed_id = f.id AND c.slug = %s 51 | )"; 52 | $countQuery .= " AND EXISTS ( 53 | SELECT 1 FROM feed_categories fc 54 | JOIN categories c ON fc.category_id = c.id 55 | WHERE fc.feed_id = f.id AND c.slug = %s 56 | )"; 57 | $queryParams[] = $categorySlug; 58 | } 59 | 60 | if ($tagSlug) { 61 | $query .= " AND EXISTS ( 62 | SELECT 1 FROM feed_tags ft 63 | JOIN tags t ON ft.tag_id = t.id 64 | WHERE ft.feed_id = f.id AND t.slug = %s 65 | )"; 66 | $countQuery .= " AND EXISTS ( 67 | SELECT 1 FROM feed_tags ft 68 | JOIN tags t ON ft.tag_id = t.id 69 | WHERE ft.feed_id = f.id AND t.slug = %s 70 | )"; 71 | $queryParams[] = $tagSlug; 72 | } 73 | 74 | $totalCount = DB::queryFirstField($countQuery, ...$queryParams); 75 | $totalPages = ceil($totalCount / $perPage); 76 | 77 | $query .= " ORDER BY f.title LIMIT %i, %i"; 78 | $finalQueryParams = [...$queryParams, $offset, $perPage]; 79 | 80 | $feeds = DB::query($query, ...$finalQueryParams); 81 | 82 | // Get categories and tags for each feed 83 | foreach ($feeds as &$feed) { 84 | $feed['categories'] = DB::query(" 85 | SELECT c.* FROM categories c 86 | JOIN feed_categories fc ON c.id = fc.category_id 87 | WHERE fc.feed_id = %i 88 | ORDER BY c.name 89 | ", $feed['id']); 90 | 91 | $feed['tags'] = DB::query(" 92 | SELECT t.* FROM tags t 93 | JOIN feed_tags ft ON t.id = ft.tag_id 94 | WHERE ft.feed_id = %i 95 | ORDER BY t.name 96 | ", $feed['id']); 97 | } 98 | 99 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 100 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 101 | 102 | $html = $this->templates->render('feeds', [ 103 | 'feeds' => $feeds, 104 | 'categories' => $categories, 105 | 'tags' => $tags, 106 | 'selectedCategory' => $categorySlug, 107 | 'selectedTag' => $tagSlug, 108 | 'pagination' => [ 109 | 'current' => $page, 110 | 'total' => $totalPages, 111 | 'baseUrl' => '/feeds/page/' 112 | ], 113 | 'title' => 'Feeds' 114 | ]); 115 | 116 | return new HtmlResponse($html); 117 | } 118 | 119 | public function json(ServerRequestInterface $request): ResponseInterface 120 | { 121 | $params = $request->getQueryParams(); 122 | $page = isset($params['page']) ? max(1, (int)$params['page']) : 1; 123 | $perPage = isset($params['per_page']) ? min(100, max(1, (int)$params['per_page'])) : 20; 124 | $offset = ($page - 1) * $perPage; 125 | 126 | // Support both single and multiple categories/tags 127 | $categorySlugs = []; 128 | if (isset($params['category'])) { 129 | $categorySlugs = [$params['category']]; 130 | } elseif (isset($params['categories'])) { 131 | $categorySlugs = array_filter(explode(',', $params['categories'])); 132 | } 133 | 134 | $tagSlugs = []; 135 | if (isset($params['tag'])) { 136 | $tagSlugs = [$params['tag']]; 137 | } elseif (isset($params['tags'])) { 138 | $tagSlugs = array_filter(explode(',', $params['tags'])); 139 | } 140 | 141 | $whereConditions = ["fi.is_visible = 1"]; 142 | $queryParams = []; 143 | 144 | if (!empty($categorySlugs)) { 145 | $placeholders = implode(',', array_fill(0, count($categorySlugs), '%s')); 146 | $whereConditions[] = "EXISTS ( 147 | SELECT 1 FROM feed_categories fc 148 | JOIN categories c ON fc.category_id = c.id 149 | WHERE fc.feed_id = f.id AND c.slug IN ($placeholders) 150 | )"; 151 | $queryParams = array_merge($queryParams, $categorySlugs); 152 | } 153 | 154 | if (!empty($tagSlugs)) { 155 | $placeholders = implode(',', array_fill(0, count($tagSlugs), '%s')); 156 | $whereConditions[] = "EXISTS ( 157 | SELECT 1 FROM feed_tags ft 158 | JOIN tags t ON ft.tag_id = t.id 159 | WHERE ft.feed_id = f.id AND t.slug IN ($placeholders) 160 | )"; 161 | $queryParams = array_merge($queryParams, $tagSlugs); 162 | } 163 | 164 | $whereClause = implode(' AND ', $whereConditions); 165 | 166 | $totalCount = DB::queryFirstField( 167 | "SELECT COUNT(*) FROM feed_items fi JOIN feeds f ON fi.feed_id = f.id WHERE " . $whereClause, 168 | ...$queryParams 169 | ); 170 | $totalPages = ceil($totalCount / $perPage); 171 | 172 | $items = DB::query(" 173 | SELECT fi.id, fi.title, fi.author, fi.content, fi.url, fi.image_url, fi.published_at, 174 | f.title as feed_title, f.site_url as feed_site_url 175 | FROM feed_items fi 176 | JOIN feeds f ON fi.feed_id = f.id 177 | WHERE " . $whereClause . " 178 | ORDER BY fi.published_at DESC 179 | LIMIT %i, %i 180 | ", ...array_merge($queryParams, [$offset, $perPage])); 181 | 182 | $formattedItems = []; 183 | foreach ($items as $item) { 184 | $author = !empty($item['author']) 185 | ? $item['author'] . ' em ' . $item['feed_title'] 186 | : $item['feed_title']; 187 | 188 | $contentWithLink = '

    Leia no ' . htmlspecialchars($item['feed_title']) . '

    ' . $item['content']; 189 | 190 | $formattedItems[] = [ 191 | 'id' => $item['id'], 192 | 'title' => $item['title'], 193 | 'author' => $author, 194 | 'content' => $contentWithLink, 195 | 'url' => $item['url'], 196 | 'image_url' => $item['image_url'], 197 | 'published_at' => $item['published_at'], 198 | 'feed' => [ 199 | 'title' => $item['feed_title'], 200 | 'site_url' => $item['feed_site_url'] 201 | ] 202 | ]; 203 | } 204 | 205 | $response = [ 206 | 'items' => $formattedItems, 207 | 'pagination' => [ 208 | 'total_items' => $totalCount, 209 | 'total_pages' => $totalPages, 210 | 'current_page' => $page, 211 | 'per_page' => $perPage 212 | ] 213 | ]; 214 | 215 | return new JsonResponse($response); 216 | } 217 | 218 | public function rss(ServerRequestInterface $request): ResponseInterface 219 | { 220 | $params = $request->getQueryParams(); 221 | $page = isset($params['page']) ? max(1, (int)$params['page']) : 1; 222 | $perPage = isset($params['per_page']) ? min(100, max(1, (int)$params['per_page'])) : 20; 223 | $offset = ($page - 1) * $perPage; 224 | 225 | // Support both single and multiple categories/tags 226 | $categorySlugs = []; 227 | if (isset($params['category'])) { 228 | $categorySlugs = [$params['category']]; 229 | } elseif (isset($params['categories'])) { 230 | $categorySlugs = array_filter(explode(',', $params['categories'])); 231 | } 232 | 233 | $tagSlugs = []; 234 | if (isset($params['tag'])) { 235 | $tagSlugs = [$params['tag']]; 236 | } elseif (isset($params['tags'])) { 237 | $tagSlugs = array_filter(explode(',', $params['tags'])); 238 | } 239 | 240 | $whereConditions = ["fi.is_visible = 1"]; 241 | $queryParams = []; 242 | 243 | if (!empty($categorySlugs)) { 244 | $placeholders = implode(',', array_fill(0, count($categorySlugs), '%s')); 245 | $whereConditions[] = "EXISTS ( 246 | SELECT 1 FROM feed_categories fc 247 | JOIN categories c ON fc.category_id = c.id 248 | WHERE fc.feed_id = f.id AND c.slug IN ($placeholders) 249 | )"; 250 | $queryParams = array_merge($queryParams, $categorySlugs); 251 | } 252 | 253 | if (!empty($tagSlugs)) { 254 | $placeholders = implode(',', array_fill(0, count($tagSlugs), '%s')); 255 | $whereConditions[] = "EXISTS ( 256 | SELECT 1 FROM feed_tags ft 257 | JOIN tags t ON ft.tag_id = t.id 258 | WHERE ft.feed_id = f.id AND t.slug IN ($placeholders) 259 | )"; 260 | $queryParams = array_merge($queryParams, $tagSlugs); 261 | } 262 | 263 | $whereClause = implode(' AND ', $whereConditions); 264 | 265 | $items = DB::query(" 266 | SELECT fi.id, fi.title, fi.author, fi.content, fi.url, fi.image_url, fi.published_at, 267 | f.title as feed_title, f.site_url as feed_site_url 268 | FROM feed_items fi 269 | JOIN feeds f ON fi.feed_id = f.id 270 | WHERE " . $whereClause . " 271 | ORDER BY fi.published_at DESC 272 | LIMIT %i, %i 273 | ", ...array_merge($queryParams, [$offset, $perPage])); 274 | 275 | $xml = new \SimpleXMLElement(''); 276 | 277 | $channel = $xml->addChild('channel'); 278 | $channel->addChild('title', $_ENV['APP_NAME']); 279 | $channel->addChild('link', $_ENV['APP_URL']); 280 | $channel->addChild('description', 'Diretório e buscador de blogs pessoais atualizado em tempo real.'); 281 | $channel->addChild('language', 'pt-br'); 282 | $channel->addChild('pubDate', date('r')); 283 | 284 | 285 | foreach ($items as $item) { 286 | $xmlItem = $channel->addChild('item'); 287 | $xmlItem->addChild('title', htmlspecialchars($item['title'])); 288 | 289 | // Se o campo author estiver vazio, preencher com o nome do source 290 | $author = !empty($item['author']) 291 | ? $item['author'] . ' em ' . $item['feed_title'] 292 | : $item['feed_title']; 293 | $xmlItem->addChild('author', htmlspecialchars($author)); 294 | 295 | $xmlItem->addChild('link', htmlspecialchars($item['url'])); 296 | $xmlItem->addChild('guid', htmlspecialchars($item['url'])); 297 | $xmlItem->addChild('pubDate', date('r', strtotime($item['published_at']))); 298 | 299 | if (!empty($item['image_url'])) { 300 | $enclosure = $xmlItem->addChild('enclosure'); 301 | $enclosure->addAttribute('url', htmlspecialchars($item['image_url'])); 302 | $enclosure->addAttribute('type', 'image/jpeg'); 303 | } 304 | 305 | $contentWithLink = '

    Leia no ' . htmlspecialchars($item['feed_title']) . '

    ' . $item['content']; 306 | 307 | $description = $xmlItem->addChild('description'); 308 | $node = dom_import_simplexml($description); 309 | $owner = $node->ownerDocument; 310 | $node->appendChild($owner->createCDATASection($contentWithLink)); 311 | 312 | $source = $xmlItem->addChild('source', htmlspecialchars($item['feed_title'])); 313 | $source->addAttribute('url', htmlspecialchars($item['feed_site_url'])); 314 | } 315 | 316 | $xmlString = $xml->asXML(); 317 | 318 | return new XmlResponse($xmlString); 319 | } 320 | 321 | public function feedBuilder(ServerRequestInterface $request): ResponseInterface 322 | { 323 | // Get all categories with item counts from the cached column 324 | $categories = DB::query("SELECT * FROM categories ORDER BY name"); 325 | 326 | // Get all tags with item counts from the cached column 327 | $tags = DB::query("SELECT * FROM tags ORDER BY name"); 328 | 329 | $html = $this->templates->render('feed-builder', [ 330 | 'categories' => $categories, 331 | 'tags' => $tags, 332 | 'title' => 'Construtor de Feed' 333 | ]); 334 | 335 | return new HtmlResponse($html); 336 | } 337 | } 338 | --------------------------------------------------------------------------------