├── 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 |
--------------------------------------------------------------------------------
/app/src/Middleware/AuthMiddleware.php:
--------------------------------------------------------------------------------
1 | handle($request);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/public/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
12 |
13 |
14 |
15 |
= __('tags.no_tags') ?>
16 |
17 |
18 |
37 |
38 |
--------------------------------------------------------------------------------
/app/templates/categories-list.php:
--------------------------------------------------------------------------------
1 | layout('layout', ['title' => 'Categorias']) ?>
2 |
3 | start('active') ?>categoriesstop() ?>
4 |
5 |
6 |
12 |
13 |
14 |
15 |
= __('categories.no_categories') ?>
16 |
17 |
18 |
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 |
16 |
17 |
18 | = $this->e($error) ?>
19 |
20 |
21 |
22 |
23 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/README.en.md:
--------------------------------------------------------------------------------
1 | # 📰 Lerama
2 |
3 | [](https://www.php.net/)
4 | [](https://www.docker.com/)
5 | [](LICENSE.md)
6 | [](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 | [](https://www.php.net/)
4 | [](https://www.docker.com/)
5 | [](LICENSE.md)
6 | [](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 |
--------------------------------------------------------------------------------
/app/templates/admin/category-form.php:
--------------------------------------------------------------------------------
1 | layout('layout', ['title' => $title]) ?>
2 |
3 | start('active') ?>admin-categoriesstop() ?>
4 |
5 |
--------------------------------------------------------------------------------
/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 |
20 |
21 |
22 |
23 |
24 |
25 | = __('admin.tags.no_tags') ?>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | | = __('common.name') ?> |
35 | = __('common.slug') ?> |
36 | = __('admin.tags.feeds') ?> |
37 | |
38 |
39 |
40 |
41 |
42 |
43 | |
44 | = $this->e($tag['name']) ?>
45 | |
46 |
47 | = $this->e($tag['slug']) ?>
48 | |
49 |
50 |
51 | = $tag['feed_count'] ?> = __('admin.tags.feeds') ?>
52 |
53 | |
54 |
55 |
56 |
57 |
58 |
59 |
62 |
63 | |
64 |
65 |
66 |
67 |
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 |
20 |
21 |
22 |
23 |
24 |
25 | = __('admin.categories.no_categories') ?>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | | = __('common.name') ?> |
35 | = __('common.slug') ?> |
36 | = __('admin.categories.feeds') ?> |
37 | |
38 |
39 |
40 |
41 |
42 |
43 | |
44 | = $this->e($category['name']) ?>
45 | |
46 |
47 | = $this->e($category['slug']) ?>
48 | |
49 |
50 |
51 | = $category['feed_count'] ?> = __('admin.categories.feeds') ?>
52 |
53 | |
54 |
55 |
56 |
57 |
58 |
59 |
62 |
63 | |
64 |
65 |
66 |
67 |
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 |
12 |
13 |
14 |
15 |
16 |
17 | = __('feed_builder.categories') ?>
18 |
19 |
20 |
21 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | = __('feed_builder.topics') ?>
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
57 |
58 |
59 |
62 |
65 |
66 |
67 |
68 |
69 |
73 |
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 | = $this->section('content') ?>
90 |
91 |
92 |
116 |
157 |
158 |
181 |
182 | = $this->section('scripts', '') ?>
183 |
184 |
185 |
--------------------------------------------------------------------------------
/app/templates/admin/items.php:
--------------------------------------------------------------------------------
1 | layout('layout', ['title' => $title]) ?>
2 |
3 | start('active') ?>adminstop() ?>
4 |
5 |
6 |
37 |
38 |
39 |
40 |
41 |
42 | = __('admin.items.no_items') ?>
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | |
52 |
53 |
54 | = __('suggest.form.title') ?>
55 |
56 | |
57 |
58 |
59 |
60 | = __('admin.items.feed') ?>
61 |
62 | |
63 |
64 |
65 |
66 | = __('admin.items.author') ?>
67 |
68 | |
69 |
70 |
71 |
72 | = __('admin.items.published') ?>
73 |
74 | |
75 | |
76 |
77 |
78 |
79 |
80 |
81 | |
82 |
83 | = $this->e($item['title']) ?>
84 |
85 |
86 | |
87 |
88 | = $this->e($item['feed_title']) ?>
89 | |
90 |
91 | = $this->e($item['author'] ?? __('admin.items.unknown_author')) ?>
92 | |
93 |
94 | = $item['published_at'] ? date('d/m/Y \à\s H:i', strtotime($item['published_at'])) : 'Nunca' ?>
95 | |
96 |
97 |
107 | |
108 |
109 |
110 |
111 |
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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | = $this->e($success) ?>
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | = $this->e($errors['general']) ?>
28 |
29 |
30 |
31 |
32 |
33 | = __('suggest.description') ?>
34 |
35 |
36 |
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 |
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 |
--------------------------------------------------------------------------------