mailn
This commit is contained in:
@@ -0,0 +1,409 @@
|
|||||||
|
# VH Posting System - Описание системы
|
||||||
|
|
||||||
|
**Версия:** 1.0
|
||||||
|
**Дата:** 2026-01-07
|
||||||
|
**Совместимость:** PHP 7.2+
|
||||||
|
**Хостинг:** reg.ru (shared hosting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общее описание
|
||||||
|
|
||||||
|
VH Posting System — веб-приложение для работы с изображениями Flickr. Позволяет конвертировать ссылки в различные форматы для публикации на форумах, блогах и социальных сетях, а также напрямую публиковать в Telegram-каналы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
VH_posting_system/
|
||||||
|
├── index.php # Главная страница (требует авторизации)
|
||||||
|
├── login.php # Страница входа
|
||||||
|
├── setup.php # Первоначальная настройка (создание админа)
|
||||||
|
├── api.php # API эндпоинты для AJAX-запросов
|
||||||
|
├── config.php # Конфигурация (API ключи) - НЕ в git
|
||||||
|
├── config.example.php # Пример конфигурации
|
||||||
|
├── auth_config.php # Данные пользователей - НЕ в git, создаётся автоматически
|
||||||
|
├── .htaccess # Настройки Apache
|
||||||
|
├── .gitignore # Исключения для git
|
||||||
|
│
|
||||||
|
├── classes/ # PHP классы
|
||||||
|
│ ├── Auth.php # Система авторизации
|
||||||
|
│ ├── FlickrAPI.php # Клиент Flickr API
|
||||||
|
│ ├── FlickrParser.php # Парсер ссылок Flickr
|
||||||
|
│ ├── FormatGenerator.php # Генератор форматов вывода
|
||||||
|
│ └── TelegramBot.php # Клиент Telegram Bot API
|
||||||
|
│
|
||||||
|
├── css/
|
||||||
|
│ └── style.css # Стили интерфейса
|
||||||
|
│
|
||||||
|
├── js/
|
||||||
|
│ └── app.js # Фронтенд логика
|
||||||
|
│
|
||||||
|
└── templates/ # (зарезервировано для шаблонов)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Функциональные модули
|
||||||
|
|
||||||
|
### 1. Link Converter (Конвертер ссылок)
|
||||||
|
|
||||||
|
**Назначение:** Преобразование ссылок Flickr в различные форматы.
|
||||||
|
|
||||||
|
**Поддерживаемые входные форматы:**
|
||||||
|
- Страница фото: `https://www.flickr.com/photos/username/12345678901/`
|
||||||
|
- Короткая ссылка: `https://flic.kr/p/ABC123`
|
||||||
|
- Прямая ссылка: `https://live.staticflickr.com/65535/12345678901_abcdef1234_b.jpg`
|
||||||
|
|
||||||
|
**Форматы вывода:**
|
||||||
|
|
||||||
|
| Ключ | Название | Шаблон | Применение |
|
||||||
|
|------|----------|--------|------------|
|
||||||
|
| `bbcode` | BBCode | `[img]{url}[/img]` | Форумы phpBB, vBulletin, IPB |
|
||||||
|
| `bbcode_linked` | BBCode (clickable) | `[url={original}][img]{url}[/img][/url]` | Кликабельные превью |
|
||||||
|
| `html` | HTML | `<img src="{url}" alt="{title}">` | Веб-сайты |
|
||||||
|
| `html_linked` | HTML (clickable) | `<a href="{original}"><img...></a>` | Кликабельные изображения |
|
||||||
|
| `html_figure` | HTML Figure | `<figure>...</figure>` | Семантическая разметка |
|
||||||
|
| `markdown` | Markdown | `` | GitHub, Reddit |
|
||||||
|
| `markdown_linked` | Markdown (clickable) | `[]({original})` | Кликабельные в Markdown |
|
||||||
|
| `url_only` | URL only | `{url}` | Просто ссылка |
|
||||||
|
| `url_list` | URL list (comma) | `{url}, {url}...` | Списки через запятую |
|
||||||
|
|
||||||
|
**Размеры изображений:**
|
||||||
|
|
||||||
|
| Размер | Суффикс | Размер (px) |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Square | `_s` | 75×75 |
|
||||||
|
| LargeSquare | `_q` | 150×150 |
|
||||||
|
| Thumbnail | `_t` | 100 |
|
||||||
|
| Small | `_m` | 240 |
|
||||||
|
| Small320 | `_n` | 320 |
|
||||||
|
| Medium | (нет) | 500 |
|
||||||
|
| Medium640 | `_z` | 640 |
|
||||||
|
| Medium800 | `_c` | 800 |
|
||||||
|
| Large | `_b` | 1024 |
|
||||||
|
| Large1600 | `_h` | 1600 |
|
||||||
|
| Large2048 | `_k` | 2048 |
|
||||||
|
| Original | `_o` | Оригинал |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Flickr Gallery (Галерея Flickr)
|
||||||
|
|
||||||
|
**Назначение:** Просмотр и выбор фотографий из аккаунта Flickr.
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- Просмотр всех фотографий пользователя
|
||||||
|
- Просмотр по альбомам (photosets)
|
||||||
|
- Поиск по названию/описанию
|
||||||
|
- Множественный выбор галочками
|
||||||
|
- Пагинация (50 фото на страницу)
|
||||||
|
- Превью в виде сетки
|
||||||
|
|
||||||
|
**Требует:** Flickr API Key + User ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Telegram Post (Публикация в Telegram)
|
||||||
|
|
||||||
|
**Назначение:** Отправка фотографий и текста в Telegram-каналы.
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- Отправка одной фотографии с подписью
|
||||||
|
- Отправка альбома (2-10 фото)
|
||||||
|
- Автоматическое разбиение больших альбомов (>10 фото)
|
||||||
|
- Выбор формата текста (HTML, Markdown, plain)
|
||||||
|
- Публикация в несколько каналов
|
||||||
|
|
||||||
|
**Ограничения Telegram:**
|
||||||
|
- Максимум 10 фото в одном альбоме
|
||||||
|
- Подпись только у первого фото в альбоме
|
||||||
|
- Бот должен быть администратором канала
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Settings (Настройки)
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- Проверка статуса подключения к Flickr API
|
||||||
|
- Проверка статуса Telegram бота
|
||||||
|
- Управление списком каналов
|
||||||
|
- Смена пароля пользователя
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Классы PHP
|
||||||
|
|
||||||
|
### Auth.php — Система авторизации
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Auth {
|
||||||
|
// Создание пользователя
|
||||||
|
createUser($username, $password): bool
|
||||||
|
|
||||||
|
// Смена пароля
|
||||||
|
changePassword($username, $oldPassword, $newPassword): bool
|
||||||
|
|
||||||
|
// Вход в систему
|
||||||
|
login($username, $password, $ip): array
|
||||||
|
|
||||||
|
// Запуск сессии
|
||||||
|
startSession($username, $token): void
|
||||||
|
|
||||||
|
// Проверка авторизации
|
||||||
|
isAuthenticated($timeout = 3600): bool
|
||||||
|
|
||||||
|
// Выход
|
||||||
|
logout(): void
|
||||||
|
|
||||||
|
// Текущий пользователь
|
||||||
|
getCurrentUser(): ?string
|
||||||
|
|
||||||
|
// Есть ли пользователи
|
||||||
|
hasUsers(): bool
|
||||||
|
|
||||||
|
// IP клиента
|
||||||
|
static getClientIP(): string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Безопасность:**
|
||||||
|
- Хеширование: Argon2ID (fallback на bcrypt)
|
||||||
|
- Защита от брутфорса: 5 попыток, блокировка 15 минут
|
||||||
|
- Таймаут сессии: 1 час
|
||||||
|
- Регенерация session ID при входе
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FlickrAPI.php — Клиент Flickr API
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FlickrAPI {
|
||||||
|
__construct($apiKey, $apiSecret, $userId = '')
|
||||||
|
|
||||||
|
// Установить User ID
|
||||||
|
setUserId($userId): void
|
||||||
|
|
||||||
|
// Получить фотографии пользователя
|
||||||
|
getPhotos($page = 1, $perPage = 50): array
|
||||||
|
|
||||||
|
// Получить альбомы
|
||||||
|
getPhotosets($page = 1, $perPage = 50): array
|
||||||
|
|
||||||
|
// Фотографии из альбома
|
||||||
|
getPhotosetPhotos($photosetId, $page, $perPage): array
|
||||||
|
|
||||||
|
// Информация о фото
|
||||||
|
getPhotoInfo($photoId): array
|
||||||
|
|
||||||
|
// Доступные размеры фото
|
||||||
|
getPhotoSizes($photoId): array
|
||||||
|
|
||||||
|
// Поиск фотографий
|
||||||
|
searchPhotos($query, $page, $perPage): array
|
||||||
|
|
||||||
|
// Найти User ID по имени
|
||||||
|
findUserByUsername($username): string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FlickrParser.php — Парсер ссылок
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FlickrParser {
|
||||||
|
// Парсинг одной ссылки
|
||||||
|
parse($url): ?array
|
||||||
|
|
||||||
|
// Парсинг нескольких ссылок
|
||||||
|
parseMultiple($input): array
|
||||||
|
|
||||||
|
// Построение URL изображения
|
||||||
|
buildImageUrl($photoInfo, $size = 'Large'): string
|
||||||
|
|
||||||
|
// Доступные размеры
|
||||||
|
getAvailableSizes(): array
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FormatGenerator.php — Генератор форматов
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FormatGenerator {
|
||||||
|
// Добавить кастомный формат
|
||||||
|
addFormat($key, $name, $template, $separator, $description): void
|
||||||
|
|
||||||
|
// Получить все форматы
|
||||||
|
getFormats(): array
|
||||||
|
|
||||||
|
// Сгенерировать для одного изображения
|
||||||
|
generate($formatKey, $imageData): string
|
||||||
|
|
||||||
|
// Сгенерировать для нескольких
|
||||||
|
generateMultiple($formatKey, $images): string
|
||||||
|
|
||||||
|
// Сгенерировать все форматы сразу
|
||||||
|
generateAll($images): array
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плейсхолдеры в шаблонах:**
|
||||||
|
- `{url}` — URL изображения выбранного размера
|
||||||
|
- `{original}` — URL оригинала
|
||||||
|
- `{title}` — Название фото
|
||||||
|
- `{description}` — Описание
|
||||||
|
- `{photo_id}` — ID фото
|
||||||
|
- `{width}`, `{height}` — Размеры
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TelegramBot.php — Клиент Telegram
|
||||||
|
|
||||||
|
```php
|
||||||
|
class TelegramBot {
|
||||||
|
__construct($botToken)
|
||||||
|
|
||||||
|
// Отправить текст
|
||||||
|
sendMessage($chatId, $text, $parseMode, $disablePreview): array
|
||||||
|
|
||||||
|
// Отправить фото
|
||||||
|
sendPhoto($chatId, $photo, $caption, $parseMode): array
|
||||||
|
|
||||||
|
// Отправить альбом
|
||||||
|
sendMediaGroup($chatId, $photos, $caption, $parseMode): array
|
||||||
|
|
||||||
|
// Умная публикация (авто-выбор метода)
|
||||||
|
post($chatId, $photos, $text, $parseMode): array
|
||||||
|
|
||||||
|
// Публикация в несколько каналов
|
||||||
|
postToMultiple($chatIds, $photos, $text, $parseMode): array
|
||||||
|
|
||||||
|
// Информация о боте
|
||||||
|
getMe(): array
|
||||||
|
|
||||||
|
// Информация о чате
|
||||||
|
getChat($chatId): array
|
||||||
|
|
||||||
|
// Проверка доступа к каналу
|
||||||
|
validateChannel($chatId): array
|
||||||
|
|
||||||
|
// Экранирование HTML
|
||||||
|
static escapeHtml($text): string
|
||||||
|
|
||||||
|
// Экранирование Markdown
|
||||||
|
static escapeMarkdown($text): string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (api.php)
|
||||||
|
|
||||||
|
| Action | Method | Параметры | Описание |
|
||||||
|
|--------|--------|-----------|----------|
|
||||||
|
| `convert` | POST | urls, size, format | Конвертация ссылок |
|
||||||
|
| `flickr_albums` | GET | — | Список альбомов |
|
||||||
|
| `flickr_photos` | GET | page, per_page, album_id, search | Список фото |
|
||||||
|
| `flickr_photo_sizes` | GET | photo_id | Размеры фото |
|
||||||
|
| `telegram_status` | GET | — | Статус бота |
|
||||||
|
| `telegram_channels` | GET | — | Список каналов |
|
||||||
|
| `telegram_post` | POST | channel_id, text, photos, parse_mode | Публикация |
|
||||||
|
| `change_password` | POST | current_password, new_password | Смена пароля |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация (config.php)
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'flickr' => [
|
||||||
|
'api_key' => '', // API ключ Flickr
|
||||||
|
'api_secret' => '', // API секрет
|
||||||
|
],
|
||||||
|
'flickr_user_id' => '', // ID пользователя (12345678@N00)
|
||||||
|
|
||||||
|
'telegram' => [
|
||||||
|
'bot_token' => '', // Токен бота от @BotFather
|
||||||
|
'channels' => [ // Список каналов
|
||||||
|
['id' => '@channel', 'name' => 'Название'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'default_size' => 'Large',
|
||||||
|
|
||||||
|
'custom_formats' => [ // Кастомные форматы
|
||||||
|
// 'key' => ['name' => '', 'template' => ''],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Защита файлов (.htaccess)
|
||||||
|
- Запрет листинга директорий
|
||||||
|
- Блокировка доступа к config.php, auth_config.php
|
||||||
|
- Блокировка прямого доступа к /classes/
|
||||||
|
|
||||||
|
### Защита авторизации
|
||||||
|
- Argon2ID/bcrypt хеширование
|
||||||
|
- Rate limiting (5 попыток / 15 минут блокировки)
|
||||||
|
- Session timeout (1 час)
|
||||||
|
- CSRF токены на формах
|
||||||
|
- Регенерация session ID
|
||||||
|
|
||||||
|
### Входные данные
|
||||||
|
- htmlspecialchars() для вывода
|
||||||
|
- Валидация URL при парсинге
|
||||||
|
- JSON-encode для API ответов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
### Сервер
|
||||||
|
- PHP 7.2+ (рекомендуется 7.4+)
|
||||||
|
- Расширения: curl, json, bcmath (для base58)
|
||||||
|
- Apache с mod_rewrite
|
||||||
|
|
||||||
|
### API ключи
|
||||||
|
- **Flickr:** https://www.flickr.com/services/apps/create/
|
||||||
|
- **Telegram:** @BotFather → /newbot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
1. Загрузить файлы на хостинг
|
||||||
|
2. Скопировать `config.example.php` → `config.php`
|
||||||
|
3. Заполнить API ключи в config.php
|
||||||
|
4. Открыть сайт → setup.php → создать админа
|
||||||
|
5. Войти через login.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Планируемые функции
|
||||||
|
|
||||||
|
- [ ] Drag & drop загрузка изображений
|
||||||
|
- [ ] История конвертаций
|
||||||
|
- [ ] Избранные форматы
|
||||||
|
- [ ] Темная тема
|
||||||
|
- [ ] Экспорт/импорт настроек
|
||||||
|
- [ ] Поддержка других фотохостингов (Imgur, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v1.0 (2026-01-07)
|
||||||
|
- Начальная реализация
|
||||||
|
- Конвертер ссылок Flickr
|
||||||
|
- Интеграция с Flickr API
|
||||||
|
- Интеграция с Telegram Bot API
|
||||||
|
- Система авторизации
|
||||||
|
- Адаптация под PHP 7.2 (reg.ru)
|
||||||
@@ -0,0 +1,949 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* API endpoints for AJAX requests
|
||||||
|
* VK, Telegram, Flickr integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Set timezone to Moscow
|
||||||
|
date_default_timezone_set('Europe/Moscow');
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
$configFile = __DIR__ . '/config.php';
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
echo json_encode(['error' => 'Configuration not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$config = require $configFile;
|
||||||
|
|
||||||
|
// Autoload classes
|
||||||
|
spl_autoload_register(function ($class) {
|
||||||
|
$file = __DIR__ . '/classes/' . $class . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create FlickrAPI instance with OAuth if available
|
||||||
|
*/
|
||||||
|
function createFlickrAPI($config) {
|
||||||
|
$flickr = new FlickrAPI(
|
||||||
|
$config['flickr']['api_key'],
|
||||||
|
$config['flickr']['api_secret'] ?? '',
|
||||||
|
$config['flickr_user_id'] ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add OAuth if tokens exist
|
||||||
|
if (!empty($config['flickr']['api_secret'])) {
|
||||||
|
$oauth = new FlickrOAuth(
|
||||||
|
$config['flickr']['api_key'],
|
||||||
|
$config['flickr']['api_secret']
|
||||||
|
);
|
||||||
|
if ($oauth->isAuthorized()) {
|
||||||
|
$flickr->setOAuth($oauth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $flickr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
$auth = new Auth();
|
||||||
|
if (!$auth->isAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get action
|
||||||
|
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
// ============ CONVERTER ============
|
||||||
|
|
||||||
|
case 'convert':
|
||||||
|
$urls = $_POST['urls'] ?? '';
|
||||||
|
$size = $_POST['size'] ?? 'Large';
|
||||||
|
$format = $_POST['format'] ?? 'bbcode';
|
||||||
|
|
||||||
|
$parser = new FlickrParser();
|
||||||
|
$generator = new FormatGenerator();
|
||||||
|
|
||||||
|
$parsed = $parser->parseMultiple($urls);
|
||||||
|
|
||||||
|
if (empty($parsed)) {
|
||||||
|
echo json_encode(['error' => 'No valid Flickr URLs found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = [];
|
||||||
|
$flickr = null;
|
||||||
|
|
||||||
|
// Check if we need API to get full URLs
|
||||||
|
$needApi = false;
|
||||||
|
foreach ($parsed as $item) {
|
||||||
|
if ($item['type'] === 'page' || $item['type'] === 'short') {
|
||||||
|
$needApi = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($needApi && !empty($config['flickr']['api_key'])) {
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($parsed as $item) {
|
||||||
|
$imageData = [
|
||||||
|
'photo_id' => $item['photo_id'],
|
||||||
|
'title' => 'Image',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($item['type'] === 'direct') {
|
||||||
|
// Direct URL - modify size suffix
|
||||||
|
$imageData['url'] = $parser->buildImageUrl($item, $size);
|
||||||
|
$imageData['original'] = $parser->buildImageUrl($item, 'Original');
|
||||||
|
} elseif ($flickr) {
|
||||||
|
// Need to fetch from API
|
||||||
|
try {
|
||||||
|
$info = $flickr->getPhotoInfo($item['photo_id']);
|
||||||
|
$sizes = $flickr->getPhotoSizes($item['photo_id']);
|
||||||
|
|
||||||
|
$imageData['title'] = $info['title']['_content'] ?? 'Image';
|
||||||
|
|
||||||
|
// Get requested size
|
||||||
|
$sizeMap = [
|
||||||
|
'Large' => 'Large',
|
||||||
|
'Large1600' => 'Large 1600',
|
||||||
|
'Large2048' => 'Large 2048',
|
||||||
|
'Original' => 'Original',
|
||||||
|
'Medium640' => 'Medium 640',
|
||||||
|
'Medium' => 'Medium',
|
||||||
|
];
|
||||||
|
|
||||||
|
$sizeName = $sizeMap[$size] ?? 'Large';
|
||||||
|
$imageData['url'] = $sizes[$sizeName]['url'] ?? $sizes['Large']['url'] ?? '';
|
||||||
|
$imageData['original'] = $sizes['Original']['url'] ?? $sizes['Large']['url'] ?? '';
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$imageData['url'] = $item['original_url'];
|
||||||
|
$imageData['original'] = $item['original_url'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No API - use original URL
|
||||||
|
$imageData['url'] = $item['original_url'];
|
||||||
|
$imageData['original'] = $item['original_url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images[] = $imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $generator->generateMultiple($format, $images);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'output' => $output,
|
||||||
|
'count' => count($images),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ FLICKR GALLERY ============
|
||||||
|
|
||||||
|
case 'flickr_albums':
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
echo json_encode(['error' => 'Flickr API not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
$page = (int)($_GET['page'] ?? 1);
|
||||||
|
$perPage = (int)($_GET['per_page'] ?? 50);
|
||||||
|
$result = $flickr->getPhotosets($page, $perPage);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'albums' => $result['albums'],
|
||||||
|
'page' => $result['page'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'flickr_photos':
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
echo json_encode(['error' => 'Flickr API not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
$page = (int)($_GET['page'] ?? 1);
|
||||||
|
$perPage = (int)($_GET['per_page'] ?? 50);
|
||||||
|
$albumId = $_GET['album_id'] ?? '';
|
||||||
|
$search = $_GET['search'] ?? '';
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$result = $flickr->searchPhotos($search, $page, $perPage);
|
||||||
|
} elseif ($albumId) {
|
||||||
|
$result = $flickr->getPhotosetPhotos($albumId, $page, $perPage);
|
||||||
|
} else {
|
||||||
|
$result = $flickr->getPhotos($page, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'photos' => $result['photos'],
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $result['page'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'flickr_photo_sizes':
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
echo json_encode(['error' => 'Flickr API not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$photoId = $_GET['photo_id'] ?? '';
|
||||||
|
if (!$photoId) {
|
||||||
|
echo json_encode(['error' => 'Photo ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
$sizes = $flickr->getPhotoSizes($photoId);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'sizes' => $sizes,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'flickr_oauth_status':
|
||||||
|
if (empty($config['flickr']['api_secret'])) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'authorized' => false,
|
||||||
|
'message' => 'API secret not configured',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oauth = new FlickrOAuth(
|
||||||
|
$config['flickr']['api_key'],
|
||||||
|
$config['flickr']['api_secret']
|
||||||
|
);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'authorized' => $oauth->isAuthorized(),
|
||||||
|
'auth_url' => 'flickr_auth.php',
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'flickr_original_url':
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
echo json_encode(['error' => 'Flickr API not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$photoId = $_GET['photo_id'] ?? '';
|
||||||
|
if (!$photoId) {
|
||||||
|
echo json_encode(['error' => 'Photo ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
$originalUrl = $flickr->getOriginalUrl($photoId);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'original_url' => $originalUrl,
|
||||||
|
'has_oauth' => $flickr->hasOAuth(),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ TELEGRAM ============
|
||||||
|
|
||||||
|
case 'telegram_status':
|
||||||
|
if (empty($config['telegram']['bot_token'])) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => false,
|
||||||
|
'message' => 'Bot token not configured',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$telegram = new TelegramBot($config['telegram']['bot_token']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$me = $telegram->getMe();
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => true,
|
||||||
|
'bot_name' => $me['first_name'] ?? '',
|
||||||
|
'bot_username' => $me['username'] ?? '',
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'telegram_channels':
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'channels' => $config['telegram']['channels'] ?? [],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'telegram_post':
|
||||||
|
if (empty($config['telegram']['bot_token'])) {
|
||||||
|
echo json_encode(['error' => 'Telegram bot not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = $_POST['channel_id'] ?? '';
|
||||||
|
$text = $_POST['text'] ?? '';
|
||||||
|
$photos = json_decode($_POST['photos'] ?? '[]', true);
|
||||||
|
$parseMode = $_POST['parse_mode'] ?? 'HTML';
|
||||||
|
|
||||||
|
if (!$channelId) {
|
||||||
|
echo json_encode(['error' => 'Channel ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$telegram = new TelegramBot($config['telegram']['bot_token']);
|
||||||
|
|
||||||
|
$result = $telegram->post($channelId, $photos, $text, $parseMode);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'result' => $result,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ VK ============
|
||||||
|
|
||||||
|
case 'vk_status':
|
||||||
|
if (empty($config['vk']['access_token'])) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => false,
|
||||||
|
'message' => 'VK access token not configured',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vk = new VKAPI($config['vk']['access_token']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$validation = $vk->validateToken();
|
||||||
|
if ($validation['valid']) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => true,
|
||||||
|
'user_name' => $validation['user_name'] ?? '',
|
||||||
|
'user_id' => $validation['user_id'] ?? '',
|
||||||
|
'type' => $validation['type'] ?? 'user',
|
||||||
|
'screen_name' => $validation['screen_name'] ?? '',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => false,
|
||||||
|
'message' => $validation['error'] ?? 'Invalid token',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vk_groups':
|
||||||
|
if (empty($config['vk']['access_token'])) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'groups' => [],
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vk = new VKAPI($config['vk']['access_token']);
|
||||||
|
|
||||||
|
// First check if this is a community token
|
||||||
|
try {
|
||||||
|
$validation = $vk->validateToken();
|
||||||
|
if ($validation['valid'] && ($validation['type'] ?? '') === 'community') {
|
||||||
|
// Community token - return the community itself
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'groups' => [[
|
||||||
|
'id' => $validation['user_id'],
|
||||||
|
'name' => $validation['user_name'] ?? 'Сообщество',
|
||||||
|
'screen_name' => $validation['screen_name'] ?? '',
|
||||||
|
]],
|
||||||
|
'type' => 'community',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Continue to try groups.get
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$groups = $vk->getGroups();
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'groups' => $groups,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'groups' => [],
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vk_post':
|
||||||
|
if (empty($config['vk']['access_token'])) {
|
||||||
|
echo json_encode(['error' => 'VK not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupId = $_POST['group_id'] ?? '';
|
||||||
|
$text = $_POST['text'] ?? '';
|
||||||
|
$photos = json_decode($_POST['photos'] ?? '[]', true);
|
||||||
|
|
||||||
|
if (!$groupId) {
|
||||||
|
echo json_encode(['error' => 'Group ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vk = new VKAPI($config['vk']['access_token']);
|
||||||
|
|
||||||
|
$result = $vk->post($groupId, $photos, $text);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'result' => $result,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ FILE UPLOAD ============
|
||||||
|
|
||||||
|
case 'upload_file':
|
||||||
|
if (empty($_FILES['file'])) {
|
||||||
|
echo json_encode(['error' => 'No file uploaded']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
|
||||||
|
|
||||||
|
if (!in_array($file['type'], $allowedTypes)) {
|
||||||
|
echo json_encode(['error' => 'Неподдерживаемый тип файла: ' . $file['type']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > 50 * 1024 * 1024) {
|
||||||
|
echo json_encode(['error' => 'Файл слишком большой (макс 50MB)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create uploads directory
|
||||||
|
$uploadsDir = __DIR__ . '/uploads';
|
||||||
|
if (!is_dir($uploadsDir)) {
|
||||||
|
mkdir($uploadsDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||||
|
$filename = uniqid('upload_') . '_' . time() . '.' . $ext;
|
||||||
|
$filepath = $uploadsDir . '/' . $filename;
|
||||||
|
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $filepath)) {
|
||||||
|
// Get the URL
|
||||||
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
|
$path = dirname($_SERVER['REQUEST_URI']);
|
||||||
|
$url = $protocol . '://' . $host . $path . '/uploads/' . $filename;
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'url' => $url,
|
||||||
|
'filename' => $filename,
|
||||||
|
'type' => $file['type'],
|
||||||
|
'size' => $file['size']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить файл']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ MULTI-PLATFORM POSTING ============
|
||||||
|
|
||||||
|
case 'multi_post':
|
||||||
|
$platforms = json_decode($_POST['platforms'] ?? '[]', true);
|
||||||
|
$text = $_POST['text'] ?? '';
|
||||||
|
$photos = json_decode($_POST['photos'] ?? '[]', true);
|
||||||
|
$uploadedFiles = json_decode($_POST['uploaded_files'] ?? '[]', true);
|
||||||
|
$parseMode = $_POST['parse_mode'] ?? 'HTML';
|
||||||
|
|
||||||
|
// Merge Flickr photos and uploaded files
|
||||||
|
if (!empty($uploadedFiles)) {
|
||||||
|
foreach ($uploadedFiles as $uploadedFile) {
|
||||||
|
if (!empty($uploadedFile['url'])) {
|
||||||
|
$photos[] = $uploadedFile['url'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($platforms)) {
|
||||||
|
echo json_encode(['error' => 'No platforms selected']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($platforms as $platform) {
|
||||||
|
$type = $platform['type'] ?? '';
|
||||||
|
$target = $platform['target'] ?? '';
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'telegram':
|
||||||
|
if (empty($config['telegram']['bot_token'])) {
|
||||||
|
$results['telegram'] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Telegram not configured',
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$telegram = new TelegramBot($config['telegram']['bot_token']);
|
||||||
|
$result = $telegram->post($target, $photos, $text, $parseMode);
|
||||||
|
$results['telegram'] = [
|
||||||
|
'success' => true,
|
||||||
|
'result' => $result,
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results['telegram'] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vk':
|
||||||
|
if (empty($config['vk']['access_token'])) {
|
||||||
|
$results['vk'] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'VK not configured',
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$vk = new VKAPI($config['vk']['access_token']);
|
||||||
|
// VK uses plain text, remove HTML/Markdown formatting
|
||||||
|
$vkText = strip_tags($text);
|
||||||
|
$result = $vk->post($target, $photos, $vkText);
|
||||||
|
$vkResult = [
|
||||||
|
'success' => true,
|
||||||
|
'result' => $result,
|
||||||
|
];
|
||||||
|
// Include warning if photos were posted as links
|
||||||
|
if (isset($result['warning'])) {
|
||||||
|
$vkResult['warning'] = $result['warning'];
|
||||||
|
}
|
||||||
|
$results['vk'] = $vkResult;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results['vk'] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'instagram':
|
||||||
|
// Instagram requires Facebook Business API
|
||||||
|
$results['instagram'] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Instagram posting requires Facebook Business API setup',
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$results[$type] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Unknown platform',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'results' => $results,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ TAG PRESETS ============
|
||||||
|
|
||||||
|
case 'get_presets':
|
||||||
|
$presetsFile = __DIR__ . '/data/tag_presets.json';
|
||||||
|
if (file_exists($presetsFile)) {
|
||||||
|
$presets = json_decode(file_get_contents($presetsFile), true);
|
||||||
|
echo json_encode(['success' => true, 'presets' => $presets ?: []]);
|
||||||
|
} else {
|
||||||
|
// Return default presets
|
||||||
|
$defaultPresets = [
|
||||||
|
['id' => 1, 'name' => 'BJD', 'tags' => ['bjd', 'doll', 'куклы']],
|
||||||
|
['id' => 2, 'name' => 'Фото', 'tags' => ['фото', 'photo', 'photography']],
|
||||||
|
['id' => 3, 'name' => 'Арт', 'tags' => ['art', 'artwork', 'творчество']],
|
||||||
|
['id' => 4, 'name' => 'Handmade', 'tags' => ['handmade', 'ручнаяработа']],
|
||||||
|
['id' => 5, 'name' => 'Faceup', 'tags' => ['faceup', 'мейк']],
|
||||||
|
['id' => 6, 'name' => 'Outfit', 'tags' => ['outfit', 'одежда']],
|
||||||
|
];
|
||||||
|
echo json_encode(['success' => true, 'presets' => $defaultPresets]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'save_presets':
|
||||||
|
$presetsFile = __DIR__ . '/data/tag_presets.json';
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$presets = $input['presets'] ?? [];
|
||||||
|
|
||||||
|
// Validate presets structure
|
||||||
|
if (!is_array($presets)) {
|
||||||
|
echo json_encode(['error' => 'Invalid presets format']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
$dataDir = __DIR__ . '/data';
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save presets
|
||||||
|
if (file_put_contents($presetsFile, json_encode($presets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Presets saved']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Failed to save presets']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ SCHEDULED POSTS ============
|
||||||
|
|
||||||
|
case 'get_scheduled_posts':
|
||||||
|
$scheduledFile = __DIR__ . '/data/scheduled_posts.json';
|
||||||
|
if (file_exists($scheduledFile)) {
|
||||||
|
$posts = json_decode(file_get_contents($scheduledFile), true) ?: [];
|
||||||
|
// Sort by scheduled time
|
||||||
|
usort($posts, function($a, $b) {
|
||||||
|
return strtotime($a['scheduled_time']) - strtotime($b['scheduled_time']);
|
||||||
|
});
|
||||||
|
echo json_encode(['success' => true, 'posts' => $posts]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => true, 'posts' => []]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'create_scheduled_post':
|
||||||
|
$scheduledFile = __DIR__ . '/data/scheduled_posts.json';
|
||||||
|
$dataDir = __DIR__ . '/data';
|
||||||
|
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = file_exists($scheduledFile) ? json_decode(file_get_contents($scheduledFile), true) ?: [] : [];
|
||||||
|
|
||||||
|
$newPost = [
|
||||||
|
'id' => uniqid('sched_'),
|
||||||
|
'text' => $_POST['text'] ?? '',
|
||||||
|
'tags' => json_decode($_POST['tags'] ?? '[]', true),
|
||||||
|
'photos' => json_decode($_POST['photos'] ?? '[]', true),
|
||||||
|
'uploaded_files' => json_decode($_POST['uploaded_files'] ?? '[]', true),
|
||||||
|
'platforms' => json_decode($_POST['platforms'] ?? '[]', true),
|
||||||
|
'scheduled_time' => $_POST['scheduled_time'] ?? '',
|
||||||
|
'cross_promo' => ($_POST['cross_promo'] ?? '0') === '1',
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'status' => 'pending'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (empty($newPost['scheduled_time'])) {
|
||||||
|
echo json_encode(['error' => 'Укажите дату и время публикации']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtotime($newPost['scheduled_time']) < time()) {
|
||||||
|
echo json_encode(['error' => 'Нельзя запланировать на прошедшее время']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts[] = $newPost;
|
||||||
|
|
||||||
|
if (file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||||
|
echo json_encode(['success' => true, 'post' => $newPost]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'update_scheduled_post':
|
||||||
|
$scheduledFile = __DIR__ . '/data/scheduled_posts.json';
|
||||||
|
$postId = $_POST['id'] ?? '';
|
||||||
|
|
||||||
|
if (!$postId) {
|
||||||
|
echo json_encode(['error' => 'ID поста не указан']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = file_exists($scheduledFile) ? json_decode(file_get_contents($scheduledFile), true) ?: [] : [];
|
||||||
|
|
||||||
|
$found = false;
|
||||||
|
foreach ($posts as &$post) {
|
||||||
|
if ($post['id'] === $postId && $post['status'] === 'pending') {
|
||||||
|
$post['text'] = $_POST['text'] ?? $post['text'];
|
||||||
|
$post['tags'] = isset($_POST['tags']) ? json_decode($_POST['tags'], true) : $post['tags'];
|
||||||
|
$post['photos'] = isset($_POST['photos']) ? json_decode($_POST['photos'], true) : $post['photos'];
|
||||||
|
$post['uploaded_files'] = isset($_POST['uploaded_files']) ? json_decode($_POST['uploaded_files'], true) : $post['uploaded_files'];
|
||||||
|
$post['platforms'] = isset($_POST['platforms']) ? json_decode($_POST['platforms'], true) : $post['platforms'];
|
||||||
|
$post['scheduled_time'] = $_POST['scheduled_time'] ?? $post['scheduled_time'];
|
||||||
|
$post['cross_promo'] = isset($_POST['cross_promo']) ? ($_POST['cross_promo'] === '1') : ($post['cross_promo'] ?? false);
|
||||||
|
$post['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
echo json_encode(['error' => 'Пост не найден или уже опубликован']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Пост обновлён']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete_scheduled_post':
|
||||||
|
$scheduledFile = __DIR__ . '/data/scheduled_posts.json';
|
||||||
|
$postId = $_POST['id'] ?? '';
|
||||||
|
|
||||||
|
if (!$postId) {
|
||||||
|
echo json_encode(['error' => 'ID поста не указан']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = file_exists($scheduledFile) ? json_decode(file_get_contents($scheduledFile), true) ?: [] : [];
|
||||||
|
$initialCount = count($posts);
|
||||||
|
|
||||||
|
$posts = array_filter($posts, function($post) use ($postId) {
|
||||||
|
return $post['id'] !== $postId || $post['status'] !== 'pending';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count($posts) === $initialCount) {
|
||||||
|
echo json_encode(['error' => 'Пост не найден или уже опубликован']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = array_values($posts); // Re-index
|
||||||
|
|
||||||
|
if (file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Пост удалён']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get_published_posts':
|
||||||
|
$scheduledFile = __DIR__ . '/data/scheduled_posts.json';
|
||||||
|
if (file_exists($scheduledFile)) {
|
||||||
|
$posts = json_decode(file_get_contents($scheduledFile), true) ?: [];
|
||||||
|
// Filter only published posts
|
||||||
|
$published = array_filter($posts, function($p) {
|
||||||
|
return $p['status'] === 'published';
|
||||||
|
});
|
||||||
|
// Sort by published_at descending (newest first)
|
||||||
|
usort($published, function($a, $b) {
|
||||||
|
return strtotime($b['published_at'] ?? $b['scheduled_time']) - strtotime($a['published_at'] ?? $a['scheduled_time']);
|
||||||
|
});
|
||||||
|
// Return last 10
|
||||||
|
$published = array_slice($published, 0, 10);
|
||||||
|
echo json_encode(['success' => true, 'posts' => array_values($published)]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => true, 'posts' => []]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ CROSS-PROMO SETTINGS ============
|
||||||
|
|
||||||
|
case 'save_cross_promo':
|
||||||
|
$settingsFile = __DIR__ . '/data/cross_promo.json';
|
||||||
|
$dataDir = __DIR__ . '/data';
|
||||||
|
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = [
|
||||||
|
'telegramLink' => trim($_POST['telegramLink'] ?? ''),
|
||||||
|
'vkLink' => trim($_POST['vkLink'] ?? ''),
|
||||||
|
'textForTg' => trim($_POST['textForTg'] ?? 'Мой канал ВКонтакте'),
|
||||||
|
'textForVk' => trim($_POST['textForVk'] ?? 'Мой канал в Telegram')
|
||||||
|
];
|
||||||
|
|
||||||
|
if (file_put_contents($settingsFile, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get_cross_promo':
|
||||||
|
$settingsFile = __DIR__ . '/data/cross_promo.json';
|
||||||
|
if (file_exists($settingsFile)) {
|
||||||
|
$settings = json_decode(file_get_contents($settingsFile), true) ?: [];
|
||||||
|
echo json_encode(['success' => true, 'settings' => $settings]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => true, 'settings' => [
|
||||||
|
'telegramLink' => '',
|
||||||
|
'vkLink' => '',
|
||||||
|
'textForTg' => 'Мой канал ВКонтакте',
|
||||||
|
'textForVk' => 'Мой канал в Telegram'
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ DRAFTS ============
|
||||||
|
|
||||||
|
case 'save_draft':
|
||||||
|
$draftFile = __DIR__ . '/data/draft.json';
|
||||||
|
$dataDir = __DIR__ . '/data';
|
||||||
|
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = [
|
||||||
|
'text' => $_POST['text'] ?? '',
|
||||||
|
'tags' => json_decode($_POST['tags'] ?? '[]', true),
|
||||||
|
'photos' => json_decode($_POST['photos'] ?? '[]', true),
|
||||||
|
'uploaded_files' => json_decode($_POST['uploaded_files'] ?? '[]', true),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
if (file_put_contents($draftFile, json_encode($draft, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить черновик']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get_draft':
|
||||||
|
$draftFile = __DIR__ . '/data/draft.json';
|
||||||
|
if (file_exists($draftFile)) {
|
||||||
|
$draft = json_decode(file_get_contents($draftFile), true) ?: [];
|
||||||
|
echo json_encode(['success' => true, 'draft' => $draft]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => true, 'draft' => null]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clear_draft':
|
||||||
|
$draftFile = __DIR__ . '/data/draft.json';
|
||||||
|
if (file_exists($draftFile)) {
|
||||||
|
unlink($draftFile);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ============ SETTINGS ============
|
||||||
|
|
||||||
|
case 'save_vk_token':
|
||||||
|
$token = trim($_POST['token'] ?? '');
|
||||||
|
|
||||||
|
if (empty($token)) {
|
||||||
|
echo json_encode(['error' => 'Токен не может быть пустым']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config.php with new VK token
|
||||||
|
$configFile = __DIR__ . '/config.php';
|
||||||
|
$configContent = file_get_contents($configFile);
|
||||||
|
|
||||||
|
// Check if vk section exists
|
||||||
|
if (strpos($configContent, "'vk'") !== false) {
|
||||||
|
// Update existing vk access_token
|
||||||
|
$configContent = preg_replace(
|
||||||
|
"/('vk'\s*=>\s*\[\s*'access_token'\s*=>\s*')[^']*(')/s",
|
||||||
|
"$1" . addslashes($token) . "$2",
|
||||||
|
$configContent
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add vk section before the closing ];
|
||||||
|
$vkSection = "\n 'vk' => [\n 'access_token' => '" . addslashes($token) . "',\n ],\n";
|
||||||
|
$configContent = preg_replace("/(\];)\s*$/", $vkSection . "$1", $configContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($configFile, $configContent)) {
|
||||||
|
// Validate the new token
|
||||||
|
require_once __DIR__ . '/classes/VKAPI.php';
|
||||||
|
$vk = new VKAPI($token);
|
||||||
|
$validation = $vk->validateToken();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Токен сохранён',
|
||||||
|
'validation' => $validation
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Не удалось сохранить config.php']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'change_password':
|
||||||
|
$currentPassword = $_POST['current_password'] ?? '';
|
||||||
|
$newPassword = $_POST['new_password'] ?? '';
|
||||||
|
|
||||||
|
if (strlen($newPassword) < 8) {
|
||||||
|
echo json_encode(['error' => 'New password must be at least 8 characters']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $auth->getCurrentUser();
|
||||||
|
if ($auth->changePassword($username, $currentPassword, $newPassword)) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Password changed successfully']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Current password is incorrect']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo json_encode(['error' => 'Unknown action']);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Simple but secure authentication system
|
||||||
|
* Compatible with PHP 5.6+ (maximum compatibility)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
private $configFile;
|
||||||
|
private $config;
|
||||||
|
private $maxAttempts = 5;
|
||||||
|
private $lockoutTime = 900;
|
||||||
|
private $passwordAlgo;
|
||||||
|
|
||||||
|
public function __construct($configFile = null)
|
||||||
|
{
|
||||||
|
if ($configFile === null) {
|
||||||
|
$this->configFile = __DIR__ . '/../auth_config.php';
|
||||||
|
} else {
|
||||||
|
$this->configFile = $configFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Argon2ID if available, fallback to bcrypt
|
||||||
|
if (defined('PASSWORD_ARGON2ID')) {
|
||||||
|
$this->passwordAlgo = PASSWORD_ARGON2ID;
|
||||||
|
} else {
|
||||||
|
$this->passwordAlgo = PASSWORD_BCRYPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadConfig()
|
||||||
|
{
|
||||||
|
if (file_exists($this->configFile)) {
|
||||||
|
$this->config = require $this->configFile;
|
||||||
|
} else {
|
||||||
|
$this->config = array(
|
||||||
|
'users' => array(),
|
||||||
|
'failed_attempts' => array(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveConfig()
|
||||||
|
{
|
||||||
|
$content = "<?php\nreturn " . var_export($this->config, true) . ";\n";
|
||||||
|
file_put_contents($this->configFile, $content);
|
||||||
|
@chmod($this->configFile, 0600);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createUser($username, $password)
|
||||||
|
{
|
||||||
|
if (isset($this->config['users'][$username])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config['users'][$username] = array(
|
||||||
|
'password_hash' => $this->hashPassword($password),
|
||||||
|
'created_at' => time(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->saveConfig();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changePassword($username, $oldPassword, $newPassword)
|
||||||
|
{
|
||||||
|
if (!$this->verifyPassword($username, $oldPassword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config['users'][$username]['password_hash'] = $this->hashPassword($newPassword);
|
||||||
|
$this->saveConfig();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hashPassword($password)
|
||||||
|
{
|
||||||
|
if ($this->passwordAlgo === PASSWORD_ARGON2ID) {
|
||||||
|
return password_hash($password, PASSWORD_ARGON2ID, array(
|
||||||
|
'memory_cost' => 65536,
|
||||||
|
'time_cost' => 4,
|
||||||
|
'threads' => 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return password_hash($password, PASSWORD_BCRYPT, array('cost' => 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifyPassword($username, $password)
|
||||||
|
{
|
||||||
|
if (!isset($this->config['users'][$username])) {
|
||||||
|
$this->hashPassword($password);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return password_verify($password, $this->config['users'][$username]['password_hash']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isLockedOut($ip)
|
||||||
|
{
|
||||||
|
if (!isset($this->config['failed_attempts'][$ip])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts = $this->config['failed_attempts'][$ip];
|
||||||
|
$lockoutTime = $this->lockoutTime;
|
||||||
|
|
||||||
|
$filtered = array();
|
||||||
|
foreach ($attempts as $time) {
|
||||||
|
if ($time > time() - $lockoutTime) {
|
||||||
|
$filtered[] = $time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->config['failed_attempts'][$ip] = $filtered;
|
||||||
|
|
||||||
|
return count($filtered) >= $this->maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordFailedAttempt($ip)
|
||||||
|
{
|
||||||
|
if (!isset($this->config['failed_attempts'][$ip])) {
|
||||||
|
$this->config['failed_attempts'][$ip] = array();
|
||||||
|
}
|
||||||
|
$this->config['failed_attempts'][$ip][] = time();
|
||||||
|
$this->saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearFailedAttempts($ip)
|
||||||
|
{
|
||||||
|
unset($this->config['failed_attempts'][$ip]);
|
||||||
|
$this->saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login($username, $password, $ip)
|
||||||
|
{
|
||||||
|
if ($this->isLockedOut($ip)) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Too many failed attempts. Please try again later.',
|
||||||
|
'locked' => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->verifyPassword($username, $password)) {
|
||||||
|
$this->clearFailedAttempts($ip);
|
||||||
|
$token = $this->generateSessionToken();
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Login successful',
|
||||||
|
'token' => $token,
|
||||||
|
'username' => $username,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordFailedAttempt($ip);
|
||||||
|
|
||||||
|
$attempts = isset($this->config['failed_attempts'][$ip]) ? $this->config['failed_attempts'][$ip] : array();
|
||||||
|
$remaining = $this->maxAttempts - count($attempts);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Invalid username or password. {$remaining} attempts remaining.",
|
||||||
|
'locked' => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateSessionToken()
|
||||||
|
{
|
||||||
|
if (function_exists('random_bytes')) {
|
||||||
|
return bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return bin2hex(openssl_random_pseudo_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startSession($username, $token)
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
|
$_SESSION['authenticated'] = true;
|
||||||
|
$_SESSION['username'] = $username;
|
||||||
|
$_SESSION['token'] = $token;
|
||||||
|
$_SESSION['login_time'] = time();
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAuthenticated($timeout = 3600)
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastActivity = isset($_SESSION['last_activity']) ? $_SESSION['last_activity'] : 0;
|
||||||
|
if (time() - $lastActivity > $timeout) {
|
||||||
|
$this->logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout()
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION = array();
|
||||||
|
|
||||||
|
if (isset($_COOKIE[session_name()])) {
|
||||||
|
setcookie(session_name(), '', time() - 3600, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentUser()
|
||||||
|
{
|
||||||
|
if (!$this->isAuthenticated()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return isset($_SESSION['username']) ? $_SESSION['username'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasUsers()
|
||||||
|
{
|
||||||
|
return !empty($this->config['users']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getClientIP()
|
||||||
|
{
|
||||||
|
$headers = array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR');
|
||||||
|
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (!empty($_SERVER[$header])) {
|
||||||
|
$ip = $_SERVER[$header];
|
||||||
|
if (strpos($ip, ',') !== false) {
|
||||||
|
$parts = explode(',', $ip);
|
||||||
|
$ip = trim($parts[0]);
|
||||||
|
}
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Flickr API Client - fetches photos from your Flickr account
|
||||||
|
* Compatible with PHP 7.2+
|
||||||
|
* Supports OAuth for accessing original quality photos
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FlickrAPI
|
||||||
|
{
|
||||||
|
private $apiKey;
|
||||||
|
private $apiSecret;
|
||||||
|
private $userId;
|
||||||
|
private $baseUrl = 'https://api.flickr.com/services/rest/';
|
||||||
|
private $oauth = null;
|
||||||
|
|
||||||
|
public function __construct($apiKey, $apiSecret, $userId = '')
|
||||||
|
{
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
$this->apiSecret = $apiSecret;
|
||||||
|
$this->userId = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set OAuth handler for authenticated requests
|
||||||
|
*/
|
||||||
|
public function setOAuth($oauth)
|
||||||
|
{
|
||||||
|
$this->oauth = $oauth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OAuth is available
|
||||||
|
*/
|
||||||
|
public function hasOAuth()
|
||||||
|
{
|
||||||
|
return $this->oauth !== null && $this->oauth->isAuthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user ID
|
||||||
|
*/
|
||||||
|
public function setUserId($userId)
|
||||||
|
{
|
||||||
|
$this->userId = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request (with OAuth if available)
|
||||||
|
*
|
||||||
|
* @param string $method Flickr API method
|
||||||
|
* @param array $params Additional parameters
|
||||||
|
* @param bool $useOAuth Force OAuth for this request
|
||||||
|
* @return array Response data
|
||||||
|
*/
|
||||||
|
private function request($method, $params = [], $useOAuth = false)
|
||||||
|
{
|
||||||
|
$params = array_merge([
|
||||||
|
'method' => $method,
|
||||||
|
'api_key' => $this->apiKey,
|
||||||
|
'format' => 'json',
|
||||||
|
'nojsoncallback' => 1,
|
||||||
|
], $params);
|
||||||
|
|
||||||
|
// Use OAuth if available and requested
|
||||||
|
if (($useOAuth || $this->hasOAuth()) && $this->oauth !== null) {
|
||||||
|
return $this->requestWithOAuth($method, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->baseUrl . '?' . http_build_query($params);
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 30,
|
||||||
|
'user_agent' => 'VH_Posting_System/1.0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
throw new RuntimeException('Failed to connect to Flickr API');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if ($data === null) {
|
||||||
|
throw new RuntimeException('Invalid JSON response from Flickr API');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['stat']) && $data['stat'] === 'fail') {
|
||||||
|
throw new RuntimeException('Flickr API error: ' . (isset($data['message']) ? $data['message'] : 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make OAuth-signed API request
|
||||||
|
*/
|
||||||
|
private function requestWithOAuth($method, $params)
|
||||||
|
{
|
||||||
|
$params['method'] = $method;
|
||||||
|
$params['format'] = 'json';
|
||||||
|
$params['nojsoncallback'] = 1;
|
||||||
|
|
||||||
|
$oauthParams = $this->oauth->signRequest('GET', $this->baseUrl, $params);
|
||||||
|
$allParams = array_merge($params, $oauthParams);
|
||||||
|
|
||||||
|
$url = $this->baseUrl . '?' . http_build_query($allParams);
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $httpCode !== 200) {
|
||||||
|
throw new RuntimeException('Failed to connect to Flickr API (OAuth)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if ($data === null) {
|
||||||
|
throw new RuntimeException('Invalid JSON response from Flickr API');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['stat']) && $data['stat'] === 'fail') {
|
||||||
|
throw new RuntimeException('Flickr API error: ' . (isset($data['message']) ? $data['message'] : 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's photostream
|
||||||
|
*
|
||||||
|
* @param int $page Page number
|
||||||
|
* @param int $perPage Photos per page (max 500)
|
||||||
|
* @return array Photos data
|
||||||
|
*/
|
||||||
|
public function getPhotos($page = 1, $perPage = 50)
|
||||||
|
{
|
||||||
|
$response = $this->request('flickr.people.getPhotos', [
|
||||||
|
'user_id' => $this->userId ? $this->userId : 'me',
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'extras' => 'url_sq,url_t,url_s,url_m,url_z,url_l,url_k,url_o,description,date_upload,date_taken,owner_name,original_format',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->normalizePhotosResponse(isset($response['photos']) ? $response['photos'] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's photosets (albums)
|
||||||
|
*
|
||||||
|
* @param int $page Page number
|
||||||
|
* @param int $perPage Albums per page
|
||||||
|
* @return array Photosets data with pagination info
|
||||||
|
*/
|
||||||
|
public function getPhotosets($page = 1, $perPage = 50)
|
||||||
|
{
|
||||||
|
$response = $this->request('flickr.photosets.getList', [
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'primary_photo_extras' => 'url_sq,url_t,url_s,url_m',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$photosets = isset($response['photosets']) ? $response['photosets'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'albums' => isset($photosets['photoset']) ? $photosets['photoset'] : [],
|
||||||
|
'page' => (int)($photosets['page'] ?? $page),
|
||||||
|
'pages' => (int)($photosets['pages'] ?? 1),
|
||||||
|
'perpage' => (int)($photosets['perpage'] ?? $perPage),
|
||||||
|
'total' => (int)($photosets['total'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get photos from a specific photoset (album)
|
||||||
|
*
|
||||||
|
* @param string $photosetId Photoset ID
|
||||||
|
* @param int $page Page number
|
||||||
|
* @param int $perPage Photos per page
|
||||||
|
* @return array Photos data
|
||||||
|
*/
|
||||||
|
public function getPhotosetPhotos($photosetId, $page = 1, $perPage = 50)
|
||||||
|
{
|
||||||
|
$response = $this->request('flickr.photosets.getPhotos', [
|
||||||
|
'photoset_id' => $photosetId,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'extras' => 'url_sq,url_t,url_s,url_m,url_z,url_l,url_k,url_o,description,date_upload,date_taken,original_format,media,path_alias,owner_name',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get owner info from photoset response
|
||||||
|
$ownername = isset($response['photoset']['ownername']) ? $response['photoset']['ownername'] : '';
|
||||||
|
$owner = isset($response['photoset']['owner']) ? $response['photoset']['owner'] : $this->userId;
|
||||||
|
|
||||||
|
return $this->normalizePhotosResponse(isset($response['photoset']) ? $response['photoset'] : [], $ownername, $owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get info about a specific photo
|
||||||
|
*
|
||||||
|
* @param string $photoId Photo ID
|
||||||
|
* @return array Photo info
|
||||||
|
*/
|
||||||
|
public function getPhotoInfo($photoId)
|
||||||
|
{
|
||||||
|
$response = $this->request('flickr.photos.getInfo', [
|
||||||
|
'photo_id' => $photoId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return isset($response['photo']) ? $response['photo'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available sizes for a photo (uses OAuth if available)
|
||||||
|
*
|
||||||
|
* @param string $photoId Photo ID
|
||||||
|
* @return array Available sizes
|
||||||
|
*/
|
||||||
|
public function getPhotoSizes($photoId)
|
||||||
|
{
|
||||||
|
// getSizes requires OAuth to return Original for private/restricted photos
|
||||||
|
$response = $this->request('flickr.photos.getSizes', [
|
||||||
|
'photo_id' => $photoId,
|
||||||
|
], true); // Force OAuth if available
|
||||||
|
|
||||||
|
$sizes = [];
|
||||||
|
$sizeList = isset($response['sizes']['size']) ? $response['sizes']['size'] : [];
|
||||||
|
foreach ($sizeList as $size) {
|
||||||
|
$sizes[$size['label']] = [
|
||||||
|
'url' => $size['source'],
|
||||||
|
'width' => (int)$size['width'],
|
||||||
|
'height' => (int)$size['height'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sizes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get original URL for a photo (requires OAuth)
|
||||||
|
*
|
||||||
|
* @param string $photoId Photo ID
|
||||||
|
* @return string|null Original URL or null if not available
|
||||||
|
*/
|
||||||
|
public function getOriginalUrl($photoId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$sizes = $this->getPhotoSizes($photoId);
|
||||||
|
|
||||||
|
// Try Original first, then fall back to largest available
|
||||||
|
if (isset($sizes['Original'])) {
|
||||||
|
return $sizes['Original']['url'];
|
||||||
|
}
|
||||||
|
if (isset($sizes['Large 2048'])) {
|
||||||
|
return $sizes['Large 2048']['url'];
|
||||||
|
}
|
||||||
|
if (isset($sizes['Large 1600'])) {
|
||||||
|
return $sizes['Large 1600']['url'];
|
||||||
|
}
|
||||||
|
if (isset($sizes['Large'])) {
|
||||||
|
return $sizes['Large']['url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search user's photos
|
||||||
|
*
|
||||||
|
* @param string $query Search query
|
||||||
|
* @param int $page Page number
|
||||||
|
* @param int $perPage Photos per page
|
||||||
|
* @return array Photos data
|
||||||
|
*/
|
||||||
|
public function searchPhotos($query, $page = 1, $perPage = 50)
|
||||||
|
{
|
||||||
|
$response = $this->request('flickr.photos.search', [
|
||||||
|
'user_id' => $this->userId ? $this->userId : 'me',
|
||||||
|
'text' => $query,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'extras' => 'url_sq,url_t,url_s,url_m,url_z,url_l,url_k,url_o,description,date_upload,date_taken,original_format',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->normalizePhotosResponse(isset($response['photos']) ? $response['photos'] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user ID by username
|
||||||
|
*
|
||||||
|
* @param string $username Flickr username
|
||||||
|
* @return string User ID
|
||||||
|
*/
|
||||||
|
public function findUserByUsername($username)
|
||||||
|
{
|
||||||
|
$response = $this->request('flickr.people.findByUsername', [
|
||||||
|
'username' => $username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return isset($response['user']['nsid']) ? $response['user']['nsid'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize photos response to consistent format
|
||||||
|
*/
|
||||||
|
private function normalizePhotosResponse($response, $defaultOwnerName = '', $defaultOwner = '')
|
||||||
|
{
|
||||||
|
$photos = [];
|
||||||
|
$photoList = isset($response['photo']) ? $response['photo'] : [];
|
||||||
|
|
||||||
|
foreach ($photoList as $photo) {
|
||||||
|
$farm = isset($photo['farm']) ? $photo['farm'] : '';
|
||||||
|
$server = $photo['server'];
|
||||||
|
$id = $photo['id'];
|
||||||
|
$originalSecret = isset($photo['originalsecret']) ? $photo['originalsecret'] : $photo['secret'];
|
||||||
|
$originalFormat = isset($photo['originalformat']) ? $photo['originalformat'] : 'jpg';
|
||||||
|
|
||||||
|
// Get original URL - ONLY use if API returns it
|
||||||
|
// If url_o is not returned, originals are blocked by Flickr privacy settings
|
||||||
|
$originalUrl = isset($photo['url_o']) ? $photo['url_o'] : null;
|
||||||
|
|
||||||
|
// Get large 2048 URL (url_k) - from API or construct it
|
||||||
|
// This is the best quality available when originals are blocked
|
||||||
|
$large2048Url = isset($photo['url_k']) ? $photo['url_k'] : null;
|
||||||
|
|
||||||
|
// Construct large2048 URL if not provided (usually works)
|
||||||
|
if (!$large2048Url && $server) {
|
||||||
|
$large2048Url = "https://live.staticflickr.com/{$server}/{$id}_{$photo['secret']}_k.jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine media type (photo or video)
|
||||||
|
$mediaType = isset($photo['media']) ? $photo['media'] : 'photo';
|
||||||
|
$isVideo = ($mediaType === 'video');
|
||||||
|
|
||||||
|
// Build page URL - use path_alias, ownername, or owner NSID
|
||||||
|
$pathAlias = isset($photo['pathalias']) && $photo['pathalias'] ? $photo['pathalias'] : '';
|
||||||
|
$ownerName = isset($photo['ownername']) ? $photo['ownername'] : $defaultOwnerName;
|
||||||
|
$owner = isset($photo['owner']) ? $photo['owner'] : ($defaultOwner ? $defaultOwner : $this->userId);
|
||||||
|
|
||||||
|
// Prefer path_alias, then ownername, then owner NSID (URL encoded)
|
||||||
|
$userPath = $pathAlias ? $pathAlias : ($ownerName ? $ownerName : rawurlencode($owner));
|
||||||
|
$pageUrl = "https://www.flickr.com/photos/{$userPath}/{$id}/";
|
||||||
|
|
||||||
|
$photos[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'secret' => $photo['secret'],
|
||||||
|
'server' => $server,
|
||||||
|
'farm' => $farm,
|
||||||
|
'title' => isset($photo['title']) ? $photo['title'] : 'Untitled',
|
||||||
|
'description' => isset($photo['description']['_content']) ? $photo['description']['_content'] : '',
|
||||||
|
'date_upload' => isset($photo['dateupload']) ? $photo['dateupload'] : '',
|
||||||
|
'date_taken' => isset($photo['datetaken']) ? $photo['datetaken'] : '',
|
||||||
|
'original_format' => $originalFormat,
|
||||||
|
'original_secret' => $originalSecret,
|
||||||
|
'media' => $mediaType,
|
||||||
|
'is_video' => $isVideo,
|
||||||
|
'urls' => [
|
||||||
|
'square' => isset($photo['url_sq']) ? $photo['url_sq'] : null,
|
||||||
|
'thumbnail' => isset($photo['url_t']) ? $photo['url_t'] : null,
|
||||||
|
'small' => isset($photo['url_s']) ? $photo['url_s'] : null,
|
||||||
|
'medium' => isset($photo['url_m']) ? $photo['url_m'] : null,
|
||||||
|
'medium640' => isset($photo['url_z']) ? $photo['url_z'] : null,
|
||||||
|
'large' => isset($photo['url_l']) ? $photo['url_l'] : null,
|
||||||
|
'large2048' => $large2048Url,
|
||||||
|
'original' => $originalUrl,
|
||||||
|
],
|
||||||
|
'page_url' => $pageUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'photos' => $photos,
|
||||||
|
'page' => (int)(isset($response['page']) ? $response['page'] : 1),
|
||||||
|
'pages' => (int)(isset($response['pages']) ? $response['pages'] : 1),
|
||||||
|
'perpage' => (int)(isset($response['perpage']) ? $response['perpage'] : count($photos)),
|
||||||
|
'total' => (int)(isset($response['total']) ? $response['total'] : count($photos)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Flickr OAuth 1.0a Authentication
|
||||||
|
* Handles authorization flow to get access tokens for API calls
|
||||||
|
*/
|
||||||
|
class FlickrOAuth
|
||||||
|
{
|
||||||
|
private $consumerKey;
|
||||||
|
private $consumerSecret;
|
||||||
|
private $requestTokenUrl = 'https://www.flickr.com/services/oauth/request_token';
|
||||||
|
private $authorizeUrl = 'https://www.flickr.com/services/oauth/authorize';
|
||||||
|
private $accessTokenUrl = 'https://www.flickr.com/services/oauth/access_token';
|
||||||
|
|
||||||
|
private $oauthToken = null;
|
||||||
|
private $oauthTokenSecret = null;
|
||||||
|
private $tokenFile;
|
||||||
|
|
||||||
|
public function __construct($consumerKey, $consumerSecret)
|
||||||
|
{
|
||||||
|
$this->consumerKey = $consumerKey;
|
||||||
|
$this->consumerSecret = $consumerSecret;
|
||||||
|
$this->tokenFile = __DIR__ . '/../data/oauth_token.json';
|
||||||
|
|
||||||
|
// Load saved tokens if exist
|
||||||
|
$this->loadTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have valid access tokens
|
||||||
|
*/
|
||||||
|
public function isAuthorized()
|
||||||
|
{
|
||||||
|
return !empty($this->oauthToken) && !empty($this->oauthTokenSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored OAuth token
|
||||||
|
*/
|
||||||
|
public function getOAuthToken()
|
||||||
|
{
|
||||||
|
return $this->oauthToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored OAuth token secret
|
||||||
|
*/
|
||||||
|
public function getOAuthTokenSecret()
|
||||||
|
{
|
||||||
|
return $this->oauthTokenSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: Get request token and return authorization URL
|
||||||
|
*/
|
||||||
|
public function getAuthorizationUrl($callbackUrl)
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'oauth_callback' => $callbackUrl,
|
||||||
|
'oauth_consumer_key' => $this->consumerKey,
|
||||||
|
'oauth_nonce' => $this->generateNonce(),
|
||||||
|
'oauth_signature_method' => 'HMAC-SHA1',
|
||||||
|
'oauth_timestamp' => time(),
|
||||||
|
'oauth_version' => '1.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
$params['oauth_signature'] = $this->generateSignature('GET', $this->requestTokenUrl, $params);
|
||||||
|
|
||||||
|
$url = $this->requestTokenUrl . '?' . http_build_query($params);
|
||||||
|
|
||||||
|
$response = $this->httpRequest($url);
|
||||||
|
|
||||||
|
if (!$response) {
|
||||||
|
throw new Exception('Failed to get request token');
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_str($response, $data);
|
||||||
|
|
||||||
|
if (!isset($data['oauth_token']) || !isset($data['oauth_token_secret'])) {
|
||||||
|
throw new Exception('Invalid request token response: ' . $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store request token temporarily (needed for step 2)
|
||||||
|
$_SESSION['flickr_request_token'] = $data['oauth_token'];
|
||||||
|
$_SESSION['flickr_request_token_secret'] = $data['oauth_token_secret'];
|
||||||
|
|
||||||
|
// Return URL for user to authorize
|
||||||
|
return $this->authorizeUrl . '?oauth_token=' . $data['oauth_token'] . '&perms=read';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: Exchange verifier for access token
|
||||||
|
*/
|
||||||
|
public function handleCallback($oauthToken, $oauthVerifier)
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['flickr_request_token_secret'])) {
|
||||||
|
throw new Exception('Request token secret not found in session');
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestTokenSecret = $_SESSION['flickr_request_token_secret'];
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'oauth_consumer_key' => $this->consumerKey,
|
||||||
|
'oauth_token' => $oauthToken,
|
||||||
|
'oauth_verifier' => $oauthVerifier,
|
||||||
|
'oauth_nonce' => $this->generateNonce(),
|
||||||
|
'oauth_signature_method' => 'HMAC-SHA1',
|
||||||
|
'oauth_timestamp' => time(),
|
||||||
|
'oauth_version' => '1.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
$params['oauth_signature'] = $this->generateSignature(
|
||||||
|
'GET',
|
||||||
|
$this->accessTokenUrl,
|
||||||
|
$params,
|
||||||
|
$requestTokenSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
$url = $this->accessTokenUrl . '?' . http_build_query($params);
|
||||||
|
|
||||||
|
$response = $this->httpRequest($url);
|
||||||
|
|
||||||
|
if (!$response) {
|
||||||
|
throw new Exception('Failed to get access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_str($response, $data);
|
||||||
|
|
||||||
|
if (!isset($data['oauth_token']) || !isset($data['oauth_token_secret'])) {
|
||||||
|
throw new Exception('Invalid access token response: ' . $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save access tokens
|
||||||
|
$this->oauthToken = $data['oauth_token'];
|
||||||
|
$this->oauthTokenSecret = $data['oauth_token_secret'];
|
||||||
|
$this->saveTokens($data);
|
||||||
|
|
||||||
|
// Clean up session
|
||||||
|
unset($_SESSION['flickr_request_token']);
|
||||||
|
unset($_SESSION['flickr_request_token_secret']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'oauth_token' => $this->oauthToken,
|
||||||
|
'user_nsid' => $data['user_nsid'] ?? null,
|
||||||
|
'username' => $data['username'] ?? null,
|
||||||
|
'fullname' => $data['fullname'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an API request with OAuth
|
||||||
|
*/
|
||||||
|
public function signRequest($method, $url, $params = [])
|
||||||
|
{
|
||||||
|
$oauthParams = [
|
||||||
|
'oauth_consumer_key' => $this->consumerKey,
|
||||||
|
'oauth_token' => $this->oauthToken,
|
||||||
|
'oauth_nonce' => $this->generateNonce(),
|
||||||
|
'oauth_signature_method' => 'HMAC-SHA1',
|
||||||
|
'oauth_timestamp' => time(),
|
||||||
|
'oauth_version' => '1.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
$allParams = array_merge($params, $oauthParams);
|
||||||
|
$oauthParams['oauth_signature'] = $this->generateSignature(
|
||||||
|
$method,
|
||||||
|
$url,
|
||||||
|
$allParams,
|
||||||
|
$this->oauthTokenSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
return $oauthParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate OAuth signature
|
||||||
|
*/
|
||||||
|
private function generateSignature($method, $url, $params, $tokenSecret = '')
|
||||||
|
{
|
||||||
|
ksort($params);
|
||||||
|
|
||||||
|
$paramString = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
|
$baseString = strtoupper($method) . '&'
|
||||||
|
. rawurlencode($url) . '&'
|
||||||
|
. rawurlencode($paramString);
|
||||||
|
|
||||||
|
$signingKey = rawurlencode($this->consumerSecret) . '&' . rawurlencode($tokenSecret);
|
||||||
|
|
||||||
|
return base64_encode(hash_hmac('sha1', $baseString, $signingKey, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random nonce
|
||||||
|
*/
|
||||||
|
private function generateNonce()
|
||||||
|
{
|
||||||
|
return md5(uniqid(mt_rand(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make HTTP request
|
||||||
|
*/
|
||||||
|
private function httpRequest($url)
|
||||||
|
{
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tokens to file
|
||||||
|
*/
|
||||||
|
private function saveTokens($data)
|
||||||
|
{
|
||||||
|
$dir = dirname($this->tokenFile);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0700, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenData = [
|
||||||
|
'oauth_token' => $data['oauth_token'],
|
||||||
|
'oauth_token_secret' => $data['oauth_token_secret'],
|
||||||
|
'user_nsid' => $data['user_nsid'] ?? null,
|
||||||
|
'username' => $data['username'] ?? null,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
file_put_contents($this->tokenFile, json_encode($tokenData, JSON_PRETTY_PRINT));
|
||||||
|
chmod($this->tokenFile, 0600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tokens from file
|
||||||
|
*/
|
||||||
|
private function loadTokens()
|
||||||
|
{
|
||||||
|
if (file_exists($this->tokenFile)) {
|
||||||
|
$data = json_decode(file_get_contents($this->tokenFile), true);
|
||||||
|
if ($data) {
|
||||||
|
$this->oauthToken = $data['oauth_token'] ?? null;
|
||||||
|
$this->oauthTokenSecret = $data['oauth_token_secret'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear saved tokens (logout)
|
||||||
|
*/
|
||||||
|
public function clearTokens()
|
||||||
|
{
|
||||||
|
$this->oauthToken = null;
|
||||||
|
$this->oauthTokenSecret = null;
|
||||||
|
if (file_exists($this->tokenFile)) {
|
||||||
|
unlink($this->tokenFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Flickr URL Parser - extracts photo info from various Flickr URL formats
|
||||||
|
* Compatible with PHP 7.2+
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FlickrParser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Flickr image size suffixes
|
||||||
|
* https://www.flickr.com/services/api/misc.urls.html
|
||||||
|
*/
|
||||||
|
const SIZES = [
|
||||||
|
'Square' => '_s', // 75x75
|
||||||
|
'LargeSquare' => '_q', // 150x150
|
||||||
|
'Thumbnail' => '_t', // 100 on longest side
|
||||||
|
'Small' => '_m', // 240 on longest side
|
||||||
|
'Small320' => '_n', // 320 on longest side
|
||||||
|
'Medium' => '', // 500 on longest side
|
||||||
|
'Medium640' => '_z', // 640 on longest side
|
||||||
|
'Medium800' => '_c', // 800 on longest side
|
||||||
|
'Large' => '_b', // 1024 on longest side
|
||||||
|
'Large1600' => '_h', // 1600 on longest side
|
||||||
|
'Large2048' => '_k', // 2048 on longest side
|
||||||
|
'Original' => '_o', // original image
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Flickr URL and extract photo information
|
||||||
|
*
|
||||||
|
* @param string $url Flickr URL
|
||||||
|
* @return array|null Photo info or null if not a valid Flickr URL
|
||||||
|
*/
|
||||||
|
public function parse($url)
|
||||||
|
{
|
||||||
|
$url = trim($url);
|
||||||
|
|
||||||
|
// Direct image URL
|
||||||
|
if (preg_match('/staticflickr\.com\/\d+\/(\d+)_([a-f0-9]+)(_[a-z])?\.(\w+)/i', $url, $matches)) {
|
||||||
|
return [
|
||||||
|
'photo_id' => $matches[1],
|
||||||
|
'secret' => $matches[2],
|
||||||
|
'size_suffix' => isset($matches[3]) ? $matches[3] : '',
|
||||||
|
'format' => $matches[4],
|
||||||
|
'type' => 'direct',
|
||||||
|
'original_url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo page URL
|
||||||
|
if (preg_match('/flickr\.com\/photos\/[^\/]+\/(\d+)/i', $url, $matches)) {
|
||||||
|
return [
|
||||||
|
'photo_id' => $matches[1],
|
||||||
|
'type' => 'page',
|
||||||
|
'original_url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short URL (flic.kr)
|
||||||
|
if (preg_match('/flic\.kr\/p\/([a-zA-Z0-9]+)/i', $url, $matches)) {
|
||||||
|
$photoId = $this->decodeBase58($matches[1]);
|
||||||
|
return [
|
||||||
|
'photo_id' => $photoId,
|
||||||
|
'type' => 'short',
|
||||||
|
'original_url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse multiple URLs (one per line or comma-separated)
|
||||||
|
*
|
||||||
|
* @param string $input Input text with URLs
|
||||||
|
* @return array Array of parsed photo info
|
||||||
|
*/
|
||||||
|
public function parseMultiple($input)
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// Split by newlines and commas
|
||||||
|
$lines = preg_split('/[\r\n,]+/', $input);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$url = trim($line);
|
||||||
|
if (empty($url)) continue;
|
||||||
|
|
||||||
|
$parsed = $this->parse($url);
|
||||||
|
if ($parsed) {
|
||||||
|
$results[] = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a direct image URL from photo info
|
||||||
|
*
|
||||||
|
* @param array $photoInfo Photo information
|
||||||
|
* @param string $size Size name from SIZES constant
|
||||||
|
* @return string Direct image URL
|
||||||
|
*/
|
||||||
|
public function buildImageUrl($photoInfo, $size = 'Large')
|
||||||
|
{
|
||||||
|
$suffix = isset(self::SIZES[$size]) ? self::SIZES[$size] : self::SIZES['Large'];
|
||||||
|
|
||||||
|
$server = isset($photoInfo['server']) ? $photoInfo['server'] : '65535';
|
||||||
|
$photoId = $photoInfo['photo_id'];
|
||||||
|
$secret = isset($photoInfo['secret']) ? $photoInfo['secret'] : (isset($photoInfo['originalsecret']) ? $photoInfo['originalsecret'] : '');
|
||||||
|
$format = isset($photoInfo['format']) ? $photoInfo['format'] : (isset($photoInfo['originalformat']) ? $photoInfo['originalformat'] : 'jpg');
|
||||||
|
|
||||||
|
// For original size, use originalsecret if available
|
||||||
|
if ($size === 'Original' && isset($photoInfo['originalsecret'])) {
|
||||||
|
$secret = $photoInfo['originalsecret'];
|
||||||
|
$format = isset($photoInfo['originalformat']) ? $photoInfo['originalformat'] : 'jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://live.staticflickr.com/{$server}/{$photoId}_{$secret}{$suffix}.{$format}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Flickr's base58 short URL to photo ID
|
||||||
|
*
|
||||||
|
* @param string $encoded Base58 encoded string
|
||||||
|
* @return string Photo ID
|
||||||
|
*/
|
||||||
|
private function decodeBase58($encoded)
|
||||||
|
{
|
||||||
|
$alphabet = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
$base = strlen($alphabet);
|
||||||
|
$decoded = '0';
|
||||||
|
|
||||||
|
for ($i = 0; $i < strlen($encoded); $i++) {
|
||||||
|
$pos = strpos($alphabet, $encoded[$i]);
|
||||||
|
$decoded = bcmul($decoded, (string)$base);
|
||||||
|
$decoded = bcadd($decoded, (string)$pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available sizes
|
||||||
|
*
|
||||||
|
* @return array Size names
|
||||||
|
*/
|
||||||
|
public function getAvailableSizes()
|
||||||
|
{
|
||||||
|
return array_keys(self::SIZES);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Format Generator - converts image URLs to various posting formats
|
||||||
|
* Compatible with PHP 7.2+
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FormatGenerator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Predefined format templates
|
||||||
|
*/
|
||||||
|
private $formats = [
|
||||||
|
'bbcode' => [
|
||||||
|
'name' => 'BBCode',
|
||||||
|
'description' => 'Standard forum BBCode',
|
||||||
|
'template' => '[img]{url}[/img]',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'bbcode_linked' => [
|
||||||
|
'name' => 'BBCode (clickable)',
|
||||||
|
'description' => 'BBCode with link to original',
|
||||||
|
'template' => '[url={original}][img]{url}[/img][/url]',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'html' => [
|
||||||
|
'name' => 'HTML',
|
||||||
|
'description' => 'HTML img tag',
|
||||||
|
'template' => '<img src="{url}" alt="{title}">',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'html_linked' => [
|
||||||
|
'name' => 'HTML (clickable)',
|
||||||
|
'description' => 'HTML img with link to original',
|
||||||
|
'template' => '<a href="{original}" target="_blank"><img src="{url}" alt="{title}"></a>',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'html_figure' => [
|
||||||
|
'name' => 'HTML Figure',
|
||||||
|
'description' => 'HTML5 figure with caption',
|
||||||
|
'template' => "<figure>\n <a href=\"{original}\" target=\"_blank\"><img src=\"{url}\" alt=\"{title}\"></a>\n <figcaption>{title}</figcaption>\n</figure>",
|
||||||
|
'separator' => "\n\n",
|
||||||
|
],
|
||||||
|
'markdown' => [
|
||||||
|
'name' => 'Markdown',
|
||||||
|
'description' => 'Markdown image syntax',
|
||||||
|
'template' => '',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'markdown_linked' => [
|
||||||
|
'name' => 'Markdown (clickable)',
|
||||||
|
'description' => 'Markdown with link to original',
|
||||||
|
'template' => '[]({original})',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'url_only' => [
|
||||||
|
'name' => 'URL only',
|
||||||
|
'description' => 'Just the image URL',
|
||||||
|
'template' => '{url}',
|
||||||
|
'separator' => "\n",
|
||||||
|
],
|
||||||
|
'url_list' => [
|
||||||
|
'name' => 'URL list (comma)',
|
||||||
|
'description' => 'Comma-separated URLs',
|
||||||
|
'template' => '{url}',
|
||||||
|
'separator' => ', ',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ============ FORUM PRESETS ============
|
||||||
|
|
||||||
|
'bjdclub' => [
|
||||||
|
'name' => 'BJDClub.ru',
|
||||||
|
'description' => 'Оптимизировано для BJDClub (phpBB)',
|
||||||
|
'template' => '[url={original}][img]{url}[/img][/url]',
|
||||||
|
'separator' => "\n\n",
|
||||||
|
'category' => 'forum',
|
||||||
|
],
|
||||||
|
'babiki' => [
|
||||||
|
'name' => 'Babiki.ru',
|
||||||
|
'description' => 'Оптимизировано для Бэбиков',
|
||||||
|
'template' => '<a href="{original}" target="_blank"><img src="{url}" alt="{title}"></a>',
|
||||||
|
'separator' => "\n\n",
|
||||||
|
'category' => 'forum',
|
||||||
|
],
|
||||||
|
'babiki_simple' => [
|
||||||
|
'name' => 'Babiki.ru (простой)',
|
||||||
|
'description' => 'Только картинки для Бэбиков',
|
||||||
|
'template' => '<img src="{url}" alt="{title}">',
|
||||||
|
'separator' => "\n",
|
||||||
|
'category' => 'forum',
|
||||||
|
],
|
||||||
|
'doll_forum' => [
|
||||||
|
'name' => 'Кукольный форум',
|
||||||
|
'description' => 'Универсальный BBCode для кукольных форумов',
|
||||||
|
'template' => '[url={original}][img]{url}[/img][/url]',
|
||||||
|
'separator' => "\n",
|
||||||
|
'category' => 'forum',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a custom format
|
||||||
|
*
|
||||||
|
* @param string $key Format key
|
||||||
|
* @param string $name Display name
|
||||||
|
* @param string $template Template with placeholders
|
||||||
|
* @param string $separator Separator between multiple images
|
||||||
|
* @param string $description Format description
|
||||||
|
*/
|
||||||
|
public function addFormat($key, $name, $template, $separator = "\n", $description = '')
|
||||||
|
{
|
||||||
|
$this->formats[$key] = [
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $description,
|
||||||
|
'template' => $template,
|
||||||
|
'separator' => $separator,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available formats
|
||||||
|
*
|
||||||
|
* @return array Format definitions
|
||||||
|
*/
|
||||||
|
public function getFormats()
|
||||||
|
{
|
||||||
|
return $this->formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate formatted output for a single image
|
||||||
|
*
|
||||||
|
* @param string $formatKey Format key
|
||||||
|
* @param array $imageData Image data with url, original, title
|
||||||
|
* @return string Formatted output
|
||||||
|
*/
|
||||||
|
public function generate($formatKey, $imageData)
|
||||||
|
{
|
||||||
|
if (!isset($this->formats[$formatKey])) {
|
||||||
|
throw new InvalidArgumentException("Unknown format: {$formatKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = $this->formats[$formatKey]['template'];
|
||||||
|
|
||||||
|
$replacements = [
|
||||||
|
'{url}' => isset($imageData['url']) ? $imageData['url'] : '',
|
||||||
|
'{original}' => isset($imageData['original']) ? $imageData['original'] : (isset($imageData['url']) ? $imageData['url'] : ''),
|
||||||
|
'{title}' => htmlspecialchars(isset($imageData['title']) ? $imageData['title'] : 'Image', ENT_QUOTES),
|
||||||
|
'{description}' => htmlspecialchars(isset($imageData['description']) ? $imageData['description'] : '', ENT_QUOTES),
|
||||||
|
'{photo_id}' => isset($imageData['photo_id']) ? $imageData['photo_id'] : '',
|
||||||
|
'{width}' => isset($imageData['width']) ? $imageData['width'] : '',
|
||||||
|
'{height}' => isset($imageData['height']) ? $imageData['height'] : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate formatted output for multiple images
|
||||||
|
*
|
||||||
|
* @param string $formatKey Format key
|
||||||
|
* @param array $images Array of image data
|
||||||
|
* @return string Formatted output
|
||||||
|
*/
|
||||||
|
public function generateMultiple($formatKey, $images)
|
||||||
|
{
|
||||||
|
if (!isset($this->formats[$formatKey])) {
|
||||||
|
throw new InvalidArgumentException("Unknown format: {$formatKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$separator = $this->formats[$formatKey]['separator'];
|
||||||
|
$outputs = [];
|
||||||
|
|
||||||
|
foreach ($images as $imageData) {
|
||||||
|
$outputs[] = $this->generate($formatKey, $imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode($separator, $outputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate output in all formats at once
|
||||||
|
*
|
||||||
|
* @param array $images Array of image data
|
||||||
|
* @return array Keyed by format key
|
||||||
|
*/
|
||||||
|
public function generateAll($images)
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach (array_keys($this->formats) as $formatKey) {
|
||||||
|
$result[$formatKey] = [
|
||||||
|
'name' => $this->formats[$formatKey]['name'],
|
||||||
|
'description' => $this->formats[$formatKey]['description'],
|
||||||
|
'output' => $this->generateMultiple($formatKey, $images),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Telegram Bot API client for posting images and text
|
||||||
|
* Compatible with PHP 7.2+
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TelegramBot
|
||||||
|
{
|
||||||
|
private $botToken;
|
||||||
|
private $baseUrl = 'https://api.telegram.org/bot';
|
||||||
|
private $defaultChannels = [];
|
||||||
|
|
||||||
|
public function __construct($botToken)
|
||||||
|
{
|
||||||
|
$this->botToken = $botToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set default channels for posting
|
||||||
|
*
|
||||||
|
* @param array $channels Array of channel usernames or IDs
|
||||||
|
*/
|
||||||
|
public function setDefaultChannels($channels)
|
||||||
|
{
|
||||||
|
$this->defaultChannels = $channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request to Telegram
|
||||||
|
*
|
||||||
|
* @param string $method API method
|
||||||
|
* @param array $params Parameters
|
||||||
|
* @return array Response
|
||||||
|
*/
|
||||||
|
private function request($method, $params = [])
|
||||||
|
{
|
||||||
|
$url = $this->baseUrl . $this->botToken . '/' . $method;
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $params,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
throw new RuntimeException("Telegram API error: {$error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (!$data['ok']) {
|
||||||
|
throw new RuntimeException("Telegram API error: " . (isset($data['description']) ? $data['description'] : 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($data['result']) ? $data['result'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message
|
||||||
|
*
|
||||||
|
* @param string $chatId Chat/Channel ID or username
|
||||||
|
* @param string $text Message text
|
||||||
|
* @param string $parseMode Parse mode (HTML, Markdown, MarkdownV2)
|
||||||
|
* @param bool $disablePreview Disable link preview
|
||||||
|
* @return array Message info
|
||||||
|
*/
|
||||||
|
public function sendMessage($chatId, $text, $parseMode = 'HTML', $disablePreview = false)
|
||||||
|
{
|
||||||
|
return $this->request('sendMessage', [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'text' => $text,
|
||||||
|
'parse_mode' => $parseMode,
|
||||||
|
'disable_web_page_preview' => $disablePreview,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single photo
|
||||||
|
*
|
||||||
|
* @param string $chatId Chat/Channel ID
|
||||||
|
* @param string $photo Photo URL or file_id
|
||||||
|
* @param string $caption Photo caption
|
||||||
|
* @param string $parseMode Parse mode for caption
|
||||||
|
* @return array Message info
|
||||||
|
*/
|
||||||
|
public function sendPhoto($chatId, $photo, $caption = '', $parseMode = 'HTML')
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'photo' => $photo,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($caption) {
|
||||||
|
$params['caption'] = $caption;
|
||||||
|
$params['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->request('sendPhoto', $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send multiple photos as media group (album)
|
||||||
|
*
|
||||||
|
* @param string $chatId Chat/Channel ID
|
||||||
|
* @param array $photos Array of photo URLs
|
||||||
|
* @param string $caption Caption for first photo
|
||||||
|
* @param string $parseMode Parse mode
|
||||||
|
* @return array Messages info
|
||||||
|
*/
|
||||||
|
public function sendMediaGroup($chatId, $photos, $caption = '', $parseMode = 'HTML')
|
||||||
|
{
|
||||||
|
$media = [];
|
||||||
|
|
||||||
|
foreach ($photos as $index => $photo) {
|
||||||
|
$item = [
|
||||||
|
'type' => 'photo',
|
||||||
|
'media' => $photo,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Caption only on first photo
|
||||||
|
if ($index === 0 && $caption) {
|
||||||
|
$item['caption'] = $caption;
|
||||||
|
$item['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$media[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->request('sendMediaGroup', [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'media' => json_encode($media),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post photos with text to a channel
|
||||||
|
* Smart method that chooses best approach based on content
|
||||||
|
*
|
||||||
|
* @param string $chatId Chat/Channel ID
|
||||||
|
* @param array $photos Array of photo URLs
|
||||||
|
* @param string $text Post text
|
||||||
|
* @param string $parseMode Parse mode
|
||||||
|
* @return array Result info
|
||||||
|
*/
|
||||||
|
public function post($chatId, $photos, $text = '', $parseMode = 'HTML')
|
||||||
|
{
|
||||||
|
$photoCount = count($photos);
|
||||||
|
|
||||||
|
// Text only
|
||||||
|
if ($photoCount === 0) {
|
||||||
|
return ['type' => 'text', 'message' => $this->sendMessage($chatId, $text, $parseMode)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single photo
|
||||||
|
if ($photoCount === 1) {
|
||||||
|
return ['type' => 'photo', 'message' => $this->sendPhoto($chatId, $photos[0], $text, $parseMode)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple photos (2-10) - use media group
|
||||||
|
if ($photoCount <= 10) {
|
||||||
|
return ['type' => 'album', 'messages' => $this->sendMediaGroup($chatId, $photos, $text, $parseMode)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// More than 10 photos - split into multiple albums
|
||||||
|
$results = [];
|
||||||
|
$chunks = array_chunk($photos, 10);
|
||||||
|
|
||||||
|
foreach ($chunks as $index => $chunk) {
|
||||||
|
$caption = ($index === 0) ? $text : '';
|
||||||
|
$results[] = $this->sendMediaGroup($chatId, $chunk, $caption, $parseMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['type' => 'multiple_albums', 'messages' => $results];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post to multiple channels at once
|
||||||
|
*
|
||||||
|
* @param array $chatIds Array of Chat/Channel IDs
|
||||||
|
* @param array $photos Array of photo URLs
|
||||||
|
* @param string $text Post text
|
||||||
|
* @param string $parseMode Parse mode
|
||||||
|
* @return array Results for each channel
|
||||||
|
*/
|
||||||
|
public function postToMultiple($chatIds, $photos, $text = '', $parseMode = 'HTML')
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($chatIds as $chatId) {
|
||||||
|
try {
|
||||||
|
$results[$chatId] = [
|
||||||
|
'success' => true,
|
||||||
|
'result' => $this->post($chatId, $photos, $text, $parseMode),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results[$chatId] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bot info
|
||||||
|
*
|
||||||
|
* @return array Bot info
|
||||||
|
*/
|
||||||
|
public function getMe()
|
||||||
|
{
|
||||||
|
return $this->request('getMe');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chat info
|
||||||
|
*
|
||||||
|
* @param string $chatId Chat/Channel ID
|
||||||
|
* @return array Chat info
|
||||||
|
*/
|
||||||
|
public function getChat($chatId)
|
||||||
|
{
|
||||||
|
return $this->request('getChat', ['chat_id' => $chatId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that bot has access to a channel
|
||||||
|
*
|
||||||
|
* @param string $chatId Channel ID or username
|
||||||
|
* @return array Validation result
|
||||||
|
*/
|
||||||
|
public function validateChannel($chatId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$chat = $this->getChat($chatId);
|
||||||
|
return [
|
||||||
|
'valid' => true,
|
||||||
|
'title' => isset($chat['title']) ? $chat['title'] : (isset($chat['username']) ? $chat['username'] : $chatId),
|
||||||
|
'type' => $chat['type'],
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format text for Telegram HTML mode
|
||||||
|
*
|
||||||
|
* @param string $text Plain text
|
||||||
|
* @return string Escaped HTML
|
||||||
|
*/
|
||||||
|
public static function escapeHtml($text)
|
||||||
|
{
|
||||||
|
return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format text for Telegram MarkdownV2 mode
|
||||||
|
*
|
||||||
|
* @param string $text Plain text
|
||||||
|
* @return string Escaped Markdown
|
||||||
|
*/
|
||||||
|
public static function escapeMarkdown($text)
|
||||||
|
{
|
||||||
|
$chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
|
||||||
|
foreach ($chars as $char) {
|
||||||
|
$text = str_replace($char, '\\' . $char, $text);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VK API Client for posting to groups/walls
|
||||||
|
* Compatible with PHP 7.2+
|
||||||
|
*
|
||||||
|
* Требования:
|
||||||
|
* - VK Access Token с правами: wall, photos, groups
|
||||||
|
* - Получить токен: https://vk.com/dev → Create App → Get Token
|
||||||
|
*/
|
||||||
|
|
||||||
|
class VKAPI
|
||||||
|
{
|
||||||
|
private $accessToken;
|
||||||
|
private $apiVersion = '5.199';
|
||||||
|
private $baseUrl = 'https://api.vk.com/method/';
|
||||||
|
private $userId;
|
||||||
|
|
||||||
|
public function __construct($accessToken)
|
||||||
|
{
|
||||||
|
$this->accessToken = $accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request to VK
|
||||||
|
*
|
||||||
|
* @param string $method API method
|
||||||
|
* @param array $params Parameters
|
||||||
|
* @return array Response
|
||||||
|
*/
|
||||||
|
private function request($method, $params = [])
|
||||||
|
{
|
||||||
|
$params['access_token'] = $this->accessToken;
|
||||||
|
$params['v'] = $this->apiVersion;
|
||||||
|
|
||||||
|
$url = $this->baseUrl . $method;
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query($params),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
throw new RuntimeException("VK API connection error: {$error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (isset($data['error'])) {
|
||||||
|
$errorMsg = isset($data['error']['error_msg']) ? $data['error']['error_msg'] : 'Unknown error';
|
||||||
|
$errorCode = isset($data['error']['error_code']) ? $data['error']['error_code'] : 0;
|
||||||
|
throw new RuntimeException("VK API error [{$errorCode}]: {$errorMsg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($data['response']) ? $data['response'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user info
|
||||||
|
*
|
||||||
|
* @return array User info
|
||||||
|
*/
|
||||||
|
public function getMe()
|
||||||
|
{
|
||||||
|
$result = $this->request('users.get', [
|
||||||
|
'fields' => 'photo_100,screen_name'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($result[0])) {
|
||||||
|
$this->userId = $result[0]['id'];
|
||||||
|
return $result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get groups where user can post
|
||||||
|
*
|
||||||
|
* @param int $count Number of groups to return
|
||||||
|
* @return array Groups list
|
||||||
|
*/
|
||||||
|
public function getGroups($count = 100)
|
||||||
|
{
|
||||||
|
$result = $this->request('groups.get', [
|
||||||
|
'extended' => 1,
|
||||||
|
'filter' => 'admin,editor,moder',
|
||||||
|
'fields' => 'name,screen_name,photo_100,can_post',
|
||||||
|
'count' => $count
|
||||||
|
]);
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
if (isset($result['items'])) {
|
||||||
|
foreach ($result['items'] as $group) {
|
||||||
|
// Only groups where posting is allowed
|
||||||
|
if (!empty($group['can_post']) || isset($group['admin_level'])) {
|
||||||
|
$groups[] = [
|
||||||
|
'id' => '-' . $group['id'], // Negative for group wall
|
||||||
|
'name' => $group['name'],
|
||||||
|
'screen_name' => isset($group['screen_name']) ? $group['screen_name'] : '',
|
||||||
|
'photo' => isset($group['photo_100']) ? $group['photo_100'] : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload photo to VK from URL
|
||||||
|
*
|
||||||
|
* @param int $groupId Group ID (without minus, positive number)
|
||||||
|
* @param string $photoUrl Photo URL to upload
|
||||||
|
* @return string Attachment string (photo{owner}_{id})
|
||||||
|
*/
|
||||||
|
public function uploadPhotoFromUrl($groupId, $photoUrl)
|
||||||
|
{
|
||||||
|
$groupId = abs((int)$groupId);
|
||||||
|
|
||||||
|
// Get upload server
|
||||||
|
try {
|
||||||
|
$uploadServer = $this->request('photos.getWallUploadServer', [
|
||||||
|
'group_id' => $groupId
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new RuntimeException('Не удалось получить сервер загрузки VK: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($uploadServer['upload_url'])) {
|
||||||
|
throw new RuntimeException('VK не вернул URL для загрузки фото');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download photo from URL with context for HTTPS
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 30,
|
||||||
|
'user_agent' => 'VH-Posting-System/1.0'
|
||||||
|
],
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => true,
|
||||||
|
'verify_peer_name' => true
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$photoData = @file_get_contents($photoUrl, false, $context);
|
||||||
|
if ($photoData === false) {
|
||||||
|
throw new RuntimeException('Не удалось скачать фото с Flickr: ' . $photoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect mime type
|
||||||
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = $finfo->buffer($photoData) ?: 'image/jpeg';
|
||||||
|
$extension = $mimeType === 'image/png' ? 'png' : 'jpg';
|
||||||
|
|
||||||
|
// Save to temp file
|
||||||
|
$tempFile = tempnam(sys_get_temp_dir(), 'vk_photo_');
|
||||||
|
file_put_contents($tempFile, $photoData);
|
||||||
|
|
||||||
|
// Upload photo using CURLFile
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $uploadServer['upload_url'],
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'photo' => new CURLFile($tempFile, $mimeType, 'photo.' . $extension)
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$uploadResponse = curl_exec($ch);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
unlink($tempFile);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
throw new RuntimeException('Ошибка загрузки фото на VK: ' . $curlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadData = json_decode($uploadResponse, true);
|
||||||
|
if (!$uploadData) {
|
||||||
|
throw new RuntimeException('Неверный ответ от сервера VK при загрузке');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($uploadData['photo']) || $uploadData['photo'] === '[]') {
|
||||||
|
$errorMsg = isset($uploadData['error']) ? $uploadData['error'] : 'пустой ответ';
|
||||||
|
throw new RuntimeException('VK не принял фото: ' . $errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save photo
|
||||||
|
try {
|
||||||
|
$savedPhoto = $this->request('photos.saveWallPhoto', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'photo' => $uploadData['photo'],
|
||||||
|
'server' => $uploadData['server'],
|
||||||
|
'hash' => $uploadData['hash']
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new RuntimeException('Не удалось сохранить фото в VK: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($savedPhoto[0])) {
|
||||||
|
throw new RuntimeException('VK не вернул данные сохранённого фото');
|
||||||
|
}
|
||||||
|
|
||||||
|
$photo = $savedPhoto[0];
|
||||||
|
return 'photo' . $photo['owner_id'] . '_' . $photo['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post to wall/group
|
||||||
|
*
|
||||||
|
* @param int $ownerId User ID or Group ID (negative for groups)
|
||||||
|
* @param string $message Post text
|
||||||
|
* @param array $attachments Array of attachments
|
||||||
|
* @param bool $fromGroup Post from group name (only for groups)
|
||||||
|
* @return array Post info
|
||||||
|
*/
|
||||||
|
public function wallPost($ownerId, $message = '', $attachments = [], $fromGroup = true)
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'owner_id' => $ownerId,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($attachments)) {
|
||||||
|
$params['attachments'] = implode(',', $attachments);
|
||||||
|
$params['primary_attachments_mode'] = 'grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If posting to group, post from group name
|
||||||
|
if ($ownerId < 0 && $fromGroup) {
|
||||||
|
$params['from_group'] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->request('wall.post', $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post photos and text to a group
|
||||||
|
*
|
||||||
|
* @param int $groupId Group ID (will be converted to negative)
|
||||||
|
* @param array $photoUrls Array of photo URLs
|
||||||
|
* @param string $message Post text
|
||||||
|
* @return array Result
|
||||||
|
*/
|
||||||
|
public function post($groupId, $photoUrls = [], $message = '')
|
||||||
|
{
|
||||||
|
$attachments = [];
|
||||||
|
$uploadErrors = [];
|
||||||
|
$permissionError = false;
|
||||||
|
|
||||||
|
// Make sure group ID is numeric
|
||||||
|
$numericGroupId = (int)$groupId;
|
||||||
|
if ($numericGroupId > 0) {
|
||||||
|
$numericGroupId = -$numericGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to upload each photo
|
||||||
|
foreach ($photoUrls as $url) {
|
||||||
|
try {
|
||||||
|
$attachments[] = $this->uploadPhotoFromUrl(abs($numericGroupId), $url);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$errorMsg = $e->getMessage();
|
||||||
|
$uploadErrors[] = $errorMsg;
|
||||||
|
error_log("VK photo upload failed: " . $errorMsg);
|
||||||
|
|
||||||
|
// Check if it's a permission error (error 15 = Access denied, error 27 = group auth)
|
||||||
|
if (strpos($errorMsg, 'error [15]') !== false ||
|
||||||
|
strpos($errorMsg, 'error [27]') !== false ||
|
||||||
|
strpos($errorMsg, 'Access denied') !== false ||
|
||||||
|
strpos($errorMsg, 'group auth') !== false) {
|
||||||
|
$permissionError = true;
|
||||||
|
break; // Don't try other photos if it's a permission issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If permission error, try to post with photo links in text
|
||||||
|
if ($permissionError && !empty($photoUrls)) {
|
||||||
|
$photoLinks = "\n\n📷 Фото:\n" . implode("\n", $photoUrls);
|
||||||
|
$messageWithPhotos = $message . $photoLinks;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->wallPost($numericGroupId, $messageWithPhotos, []);
|
||||||
|
$result['warning'] = 'Фото добавлены как ссылки. Community-токен не поддерживает загрузку фото - нужен пользовательский токен.';
|
||||||
|
return $result;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new RuntimeException('Ошибка постинга: ' . $e->getMessage() . '. Также нет прав на загрузку фото.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all photos failed to upload for non-permission reasons, report the first error
|
||||||
|
if (!empty($photoUrls) && empty($attachments) && !empty($uploadErrors)) {
|
||||||
|
throw new RuntimeException('Ошибка загрузки фото: ' . $uploadErrors[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->wallPost($numericGroupId, $message, $attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post to multiple groups at once
|
||||||
|
*
|
||||||
|
* @param array $groupIds Array of group IDs
|
||||||
|
* @param array $photoUrls Array of photo URLs
|
||||||
|
* @param string $message Post text
|
||||||
|
* @return array Results for each group
|
||||||
|
*/
|
||||||
|
public function postToMultiple($groupIds, $photoUrls = [], $message = '')
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($groupIds as $groupId) {
|
||||||
|
try {
|
||||||
|
$results[$groupId] = [
|
||||||
|
'success' => true,
|
||||||
|
'result' => $this->post($groupId, $photoUrls, $message),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results[$groupId] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// VK rate limit: max 3 requests per second
|
||||||
|
usleep(350000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate access token (supports both user and community tokens)
|
||||||
|
*
|
||||||
|
* @return array Validation result
|
||||||
|
*/
|
||||||
|
public function validateToken()
|
||||||
|
{
|
||||||
|
// First try user token validation
|
||||||
|
try {
|
||||||
|
$user = $this->getMe();
|
||||||
|
if (!empty($user)) {
|
||||||
|
return [
|
||||||
|
'valid' => true,
|
||||||
|
'type' => 'user',
|
||||||
|
'user_id' => $user['id'],
|
||||||
|
'user_name' => trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')),
|
||||||
|
'screen_name' => $user['screen_name'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// User token failed, try community token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try community token validation using groups.getById with group_id from token
|
||||||
|
try {
|
||||||
|
// For community tokens, we can get group info using groups.getById
|
||||||
|
// The token should have access to its own group
|
||||||
|
$result = $this->request('groups.getById', [
|
||||||
|
'fields' => 'name,screen_name,photo_100'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($result['groups'][0])) {
|
||||||
|
$group = $result['groups'][0];
|
||||||
|
return [
|
||||||
|
'valid' => true,
|
||||||
|
'type' => 'community',
|
||||||
|
'user_id' => '-' . $group['id'],
|
||||||
|
'user_name' => $group['name'] ?? 'Сообщество',
|
||||||
|
'screen_name' => $group['screen_name'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// VK API v5.199+ returns in different format
|
||||||
|
if (!empty($result[0])) {
|
||||||
|
$group = $result[0];
|
||||||
|
return [
|
||||||
|
'valid' => true,
|
||||||
|
'type' => 'community',
|
||||||
|
'user_id' => '-' . $group['id'],
|
||||||
|
'user_name' => $group['name'] ?? 'Сообщество',
|
||||||
|
'screen_name' => $group['screen_name'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => false, 'error' => 'Invalid token'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Configuration file - copy to config.php and fill in your values
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Flickr API credentials
|
||||||
|
// Get your API key at: https://www.flickr.com/services/apps/create/
|
||||||
|
'flickr' => [
|
||||||
|
'api_key' => '733ecb91a48dce7be84410d49d96b57e',
|
||||||
|
'api_secret' => 'c921aaf8b60c5603',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Your Flickr user ID (e.g., '12345678@N00')
|
||||||
|
// Find it at: https://www.flickr.com/services/api/explore/flickr.people.findByUsername
|
||||||
|
'flickr_user_id' => '90307077@N07',
|
||||||
|
|
||||||
|
// Default image size for output
|
||||||
|
// Options: 'Square', 'Thumbnail', 'Small', 'Medium', 'Large', 'Original'
|
||||||
|
'default_size' => 'Large',
|
||||||
|
|
||||||
|
// Available output formats
|
||||||
|
'formats' => [
|
||||||
|
'bbcode' => '[img]{url}[/img]',
|
||||||
|
'bbcode_linked' => '[url={original}][img]{url}[/img][/url]',
|
||||||
|
'html' => '<img src="{url}" alt="{title}">',
|
||||||
|
'html_linked' => '<a href="{original}"><img src="{url}" alt="{title}"></a>',
|
||||||
|
'markdown' => '',
|
||||||
|
'markdown_linked' => '[]({original})',
|
||||||
|
'url' => '{url}',
|
||||||
|
],
|
||||||
|
];
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Configuration file - copy to config.php and fill in your values
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Flickr API credentials
|
||||||
|
// Get your API key at: https://www.flickr.com/services/apps/create/
|
||||||
|
'flickr' => [
|
||||||
|
'api_key' => '733ecb91a48dce7be84410d49d96b57e',
|
||||||
|
'api_secret' => 'c921aaf8b60c5603',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Your Flickr user ID (e.g., '12345678@N00')
|
||||||
|
// Find it at: https://www.flickr.com/services/api/explore/flickr.people.findByUsername
|
||||||
|
'flickr_user_id' => '90307077@N07',
|
||||||
|
|
||||||
|
// Default image size for output
|
||||||
|
// Options: 'Square', 'Thumbnail', 'Small', 'Medium', 'Large', 'Original'
|
||||||
|
'default_size' => 'Large',
|
||||||
|
|
||||||
|
// Available output formats
|
||||||
|
'formats' => [
|
||||||
|
'bbcode' => '[img]{url}[/img]',
|
||||||
|
'bbcode_linked' => '[url={original}][img]{url}[/img][/url]',
|
||||||
|
'html' => '<img src="{url}" alt="{title}">',
|
||||||
|
'html_linked' => '<a href="{original}"><img src="{url}" alt="{title}"></a>',
|
||||||
|
'markdown' => '',
|
||||||
|
'markdown_linked' => '[]({original})',
|
||||||
|
'url' => '{url}',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Cron script to publish scheduled posts
|
||||||
|
* Run this every minute: * * * * * php /path/to/cron_publish.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Set timezone to Moscow
|
||||||
|
date_default_timezone_set('Europe/Moscow');
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli' && !isset($_GET['cron_key'])) {
|
||||||
|
// Allow web access with secret key for hosts without cron
|
||||||
|
$configFile = __DIR__ . '/config.php';
|
||||||
|
if (file_exists($configFile)) {
|
||||||
|
$config = require $configFile;
|
||||||
|
$cronKey = $config['cron_key'] ?? '';
|
||||||
|
if (empty($cronKey) || $_GET['cron_key'] !== $cronKey) {
|
||||||
|
http_response_code(403);
|
||||||
|
die('Access denied');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(403);
|
||||||
|
die('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config - must assign to $config variable
|
||||||
|
$config = require __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/classes/VKAPI.php';
|
||||||
|
require_once __DIR__ . '/classes/TelegramBot.php';
|
||||||
|
|
||||||
|
// Log file for debugging
|
||||||
|
$logFile = __DIR__ . '/data/cron_log.txt';
|
||||||
|
function logMessage($msg) {
|
||||||
|
global $logFile;
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
file_put_contents($logFile, "[$timestamp] $msg\n", FILE_APPEND);
|
||||||
|
echo $msg . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduledFile = __DIR__ . '/data/scheduled_posts.json';
|
||||||
|
|
||||||
|
if (!file_exists($scheduledFile)) {
|
||||||
|
logMessage("No scheduled posts file");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = json_decode(file_get_contents($scheduledFile), true) ?: [];
|
||||||
|
$now = time();
|
||||||
|
$updated = false;
|
||||||
|
|
||||||
|
logMessage("Starting cron run. Now: " . date('Y-m-d H:i:s', $now) . " (" . $now . ")");
|
||||||
|
|
||||||
|
$pendingCount = count(array_filter($posts, fn($p) => $p['status'] === 'pending'));
|
||||||
|
logMessage("Found $pendingCount pending posts");
|
||||||
|
|
||||||
|
foreach ($posts as &$post) {
|
||||||
|
// Skip if not pending
|
||||||
|
if ($post['status'] !== 'pending') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduledTime = strtotime($post['scheduled_time']);
|
||||||
|
logMessage("Post {$post['id']}: scheduled for " . date('Y-m-d H:i:s', $scheduledTime) . " ($scheduledTime)");
|
||||||
|
|
||||||
|
if ($scheduledTime > $now) {
|
||||||
|
$diff = $scheduledTime - $now;
|
||||||
|
logMessage(" -> Not yet time (in $diff seconds)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage(" -> Time to publish!");
|
||||||
|
|
||||||
|
// Prepare text with tags
|
||||||
|
$baseText = $post['text'] ?? '';
|
||||||
|
$tags = $post['tags'] ?? [];
|
||||||
|
if (!empty($tags)) {
|
||||||
|
$tagsString = implode(' ', array_map(function($t) { return '#' . $t; }, $tags));
|
||||||
|
$baseText = $baseText ? $baseText . "\n\n" . $tagsString : $tagsString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all photo URLs
|
||||||
|
$photoUrls = $post['photos'] ?? [];
|
||||||
|
$uploadedFiles = $post['uploaded_files'] ?? [];
|
||||||
|
foreach ($uploadedFiles as $file) {
|
||||||
|
if (!empty($file['url'])) {
|
||||||
|
$photoUrls[] = $file['url'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage(" Photos: " . count($photoUrls));
|
||||||
|
|
||||||
|
// Check if cross-promo was enabled for this post
|
||||||
|
$crossPromoEnabled = $post['cross_promo'] ?? false;
|
||||||
|
logMessage(" Cross-promo enabled: " . ($crossPromoEnabled ? 'yes' : 'no'));
|
||||||
|
|
||||||
|
// Load cross-promo settings if enabled
|
||||||
|
$crossPromoFile = __DIR__ . '/data/cross_promo.json';
|
||||||
|
$crossPromo = [];
|
||||||
|
if ($crossPromoEnabled && file_exists($crossPromoFile)) {
|
||||||
|
$crossPromo = json_decode(file_get_contents($crossPromoFile), true) ?: [];
|
||||||
|
}
|
||||||
|
$hasCrossPromo = $crossPromoEnabled && (!empty($crossPromo['telegramLink']) || !empty($crossPromo['vkLink']));
|
||||||
|
|
||||||
|
// Check which platforms we're posting to
|
||||||
|
$platforms = $post['platforms'] ?? [];
|
||||||
|
$postingToTelegram = false;
|
||||||
|
$postingToVk = false;
|
||||||
|
foreach ($platforms as $p) {
|
||||||
|
$pType = $p['type'] ?? $p;
|
||||||
|
if ($pType === 'telegram') $postingToTelegram = true;
|
||||||
|
if ($pType === 'vk') $postingToVk = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare platform-specific texts with cross-promo
|
||||||
|
$textForTelegram = $baseText;
|
||||||
|
$textForVk = $baseText;
|
||||||
|
|
||||||
|
if ($hasCrossPromo) {
|
||||||
|
// Add VK link to Telegram posts
|
||||||
|
if (!empty($crossPromo['vkLink']) && $postingToTelegram) {
|
||||||
|
$linkText = $crossPromo['textForTg'] ?? 'Мой канал ВКонтакте';
|
||||||
|
$textForTelegram .= "\n\n<a href=\"{$crossPromo['vkLink']}\">{$linkText}</a>";
|
||||||
|
logMessage(" Cross-promo: VK link added to TG text");
|
||||||
|
}
|
||||||
|
// Add Telegram link to VK posts
|
||||||
|
if (!empty($crossPromo['telegramLink']) && $postingToVk) {
|
||||||
|
$linkText = $crossPromo['textForVk'] ?? 'Мой канал в Telegram';
|
||||||
|
$textForVk .= "\n\n{$linkText}: {$crossPromo['telegramLink']}";
|
||||||
|
logMessage(" Cross-promo: TG link added to VK text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
logMessage(" Platforms: " . json_encode($platforms));
|
||||||
|
logMessage(" TG token set: " . (!empty($config['telegram']['bot_token']) ? 'yes' : 'NO'));
|
||||||
|
logMessage(" VK token set: " . (!empty($config['vk']['access_token']) ? 'yes' : 'NO'));
|
||||||
|
|
||||||
|
if (empty($platforms)) {
|
||||||
|
logMessage(" WARNING: No platforms specified!");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($platforms as $platform) {
|
||||||
|
$type = $platform['type'] ?? $platform;
|
||||||
|
$target = $platform['target'] ?? '';
|
||||||
|
|
||||||
|
logMessage(" Processing platform: $type, target: $target");
|
||||||
|
|
||||||
|
if ($type === 'telegram') {
|
||||||
|
if (empty($config['telegram']['bot_token'])) {
|
||||||
|
logMessage(" Telegram: SKIPPED - no bot token in config");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$telegram = new TelegramBot($config['telegram']['bot_token']);
|
||||||
|
// Get first channel if no target specified
|
||||||
|
if (empty($target)) {
|
||||||
|
$channels = $telegram->getChannels();
|
||||||
|
logMessage(" Telegram channels: " . count($channels));
|
||||||
|
if (!empty($channels)) {
|
||||||
|
$target = $channels[0]['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($target) {
|
||||||
|
logMessage(" Posting to Telegram channel: $target");
|
||||||
|
$result = $telegram->post($target, $photoUrls, $textForTelegram, 'HTML');
|
||||||
|
$results['telegram'] = ['success' => true, 'result' => $result];
|
||||||
|
logMessage(" Telegram: OK");
|
||||||
|
} else {
|
||||||
|
$results['telegram'] = ['success' => false, 'error' => 'No target channel'];
|
||||||
|
logMessage(" Telegram: No target channel");
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results['telegram'] = ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
logMessage(" Telegram: ERROR - {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'vk') {
|
||||||
|
if (empty($config['vk']['access_token'])) {
|
||||||
|
logMessage(" VK: SKIPPED - no access token in config");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$vk = new VKAPI($config['vk']['access_token']);
|
||||||
|
// Get first group if no target specified
|
||||||
|
if (empty($target)) {
|
||||||
|
$validation = $vk->validateToken();
|
||||||
|
if ($validation['valid'] && ($validation['type'] ?? '') === 'community') {
|
||||||
|
$target = $validation['user_id'];
|
||||||
|
} else {
|
||||||
|
$groups = $vk->getGroups();
|
||||||
|
if (!empty($groups)) {
|
||||||
|
$target = $groups[0]['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($target) {
|
||||||
|
logMessage(" Posting to VK group: $target");
|
||||||
|
$result = $vk->post($target, $photoUrls, strip_tags($textForVk));
|
||||||
|
$results['vk'] = ['success' => true, 'result' => $result];
|
||||||
|
logMessage(" VK: OK");
|
||||||
|
} else {
|
||||||
|
$results['vk'] = ['success' => false, 'error' => 'No target group'];
|
||||||
|
logMessage(" VK: No target group");
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results['vk'] = ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
logMessage(" VK: ERROR - {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update post status
|
||||||
|
$post['status'] = 'published';
|
||||||
|
$post['published_at'] = date('Y-m-d H:i:s');
|
||||||
|
$post['results'] = $results;
|
||||||
|
$updated = true;
|
||||||
|
logMessage("Post {$post['id']} marked as published");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated posts
|
||||||
|
if ($updated) {
|
||||||
|
file_put_contents($scheduledFile, json_encode($posts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
logMessage("Posts file updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage("Done\n---");
|
||||||
+4008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
Deny from all
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Diagnostic file - DELETE after debugging!
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Show all errors
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
|
||||||
|
// ============ QUICK FIX ACTIONS ============
|
||||||
|
$fixResults = array();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['fix_action'])) {
|
||||||
|
$action = $_POST['fix_action'];
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'create_htaccess':
|
||||||
|
$htaccessContent = '# VH Posting System - Apache Configuration (reg.ru compatible)
|
||||||
|
|
||||||
|
# Prevent directory listing
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Deny access to sensitive files
|
||||||
|
<FilesMatch "(config\.php|auth_config\.php|\.example\.php)$">
|
||||||
|
Order Allow,Deny
|
||||||
|
Deny from all
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Block direct access to class files
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteRule ^classes/ - [F,L]
|
||||||
|
|
||||||
|
# Security headers (if mod_headers available)
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
</IfModule>';
|
||||||
|
|
||||||
|
if (file_put_contents(__DIR__ . '/.htaccess', $htaccessContent)) {
|
||||||
|
$fixResults[] = array('success', 'Created .htaccess file');
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', 'Failed to create .htaccess');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'create_config':
|
||||||
|
if (file_exists(__DIR__ . '/config.example.php')) {
|
||||||
|
if (copy(__DIR__ . '/config.example.php', __DIR__ . '/config.php')) {
|
||||||
|
@chmod(__DIR__ . '/config.php', 0644);
|
||||||
|
$fixResults[] = array('success', 'Created config.php from example');
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', 'Failed to copy config.example.php');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$configContent = '<?php
|
||||||
|
return [
|
||||||
|
\'flickr\' => [
|
||||||
|
\'api_key\' => \'\',
|
||||||
|
\'api_secret\' => \'\',
|
||||||
|
],
|
||||||
|
\'flickr_user_id\' => \'\',
|
||||||
|
\'telegram\' => [
|
||||||
|
\'bot_token\' => \'\',
|
||||||
|
\'channels\' => [],
|
||||||
|
],
|
||||||
|
\'default_size\' => \'Large\',
|
||||||
|
\'custom_formats\' => [],
|
||||||
|
];';
|
||||||
|
if (file_put_contents(__DIR__ . '/config.php', $configContent)) {
|
||||||
|
@chmod(__DIR__ . '/config.php', 0644);
|
||||||
|
$fixResults[] = array('success', 'Created config.php');
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', 'Failed to create config.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'fix_permissions':
|
||||||
|
$files = array(
|
||||||
|
'config.php' => 0644,
|
||||||
|
'auth_config.php' => 0600,
|
||||||
|
'.htaccess' => 0644,
|
||||||
|
);
|
||||||
|
foreach ($files as $file => $perm) {
|
||||||
|
$path = __DIR__ . '/' . $file;
|
||||||
|
if (file_exists($path)) {
|
||||||
|
if (@chmod($path, $perm)) {
|
||||||
|
$fixResults[] = array('success', "Fixed permissions for {$file}");
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', "Failed to fix permissions for {$file}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'fix_auth_config_perms':
|
||||||
|
$path = __DIR__ . '/auth_config.php';
|
||||||
|
if (file_exists($path)) {
|
||||||
|
if (@chmod($path, 0600)) {
|
||||||
|
$fixResults[] = array('success', 'Fixed auth_config.php permissions to 0600');
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', 'Failed to fix auth_config.php permissions');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('info', 'auth_config.php does not exist yet');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete_debug':
|
||||||
|
// Self-delete
|
||||||
|
if (@unlink(__FILE__)) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', 'Failed to delete debug.php - delete manually!');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reset_user':
|
||||||
|
$path = __DIR__ . '/auth_config.php';
|
||||||
|
if (file_exists($path)) {
|
||||||
|
if (@unlink($path)) {
|
||||||
|
$fixResults[] = array('success', 'Deleted auth_config.php - go to setup.php to create new user');
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('error', 'Failed to delete auth_config.php');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$fixResults[] = array('info', 'auth_config.php does not exist');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HTML OUTPUT ============
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>VH Posting System - Diagnostics</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; max-width: 900px; margin: 20px auto; padding: 0 20px; }
|
||||||
|
h1 { color: #333; border-bottom: 2px solid #2196F3; padding-bottom: 10px; }
|
||||||
|
h2 { color: #555; margin-top: 30px; }
|
||||||
|
h3 { color: #666; margin-top: 20px; }
|
||||||
|
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
|
||||||
|
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
|
||||||
|
.fix-box { background: #e3f2fd; border: 1px solid #2196F3; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||||
|
.fix-box h2 { margin-top: 0; color: #1976D2; }
|
||||||
|
.btn { display: inline-block; padding: 10px 20px; margin: 5px; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
|
||||||
|
.btn-primary { background: #2196F3; color: white; }
|
||||||
|
.btn-warning { background: #FF9800; color: white; }
|
||||||
|
.btn-danger { background: #f44336; color: white; }
|
||||||
|
.btn-success { background: #4CAF50; color: white; }
|
||||||
|
.btn:hover { opacity: 0.9; }
|
||||||
|
.result { padding: 10px 15px; margin: 5px 0; border-radius: 5px; }
|
||||||
|
.result-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #4CAF50; }
|
||||||
|
.result-error { background: #ffebee; color: #c62828; border: 1px solid #f44336; }
|
||||||
|
.result-info { background: #fff3e0; color: #e65100; border: 1px solid #FF9800; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>VH Posting System - Diagnostics</h1>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Show fix results
|
||||||
|
if (!empty($fixResults)) {
|
||||||
|
echo '<div class="fix-box">';
|
||||||
|
echo '<h2>Fix Results</h2>';
|
||||||
|
foreach ($fixResults as $result) {
|
||||||
|
echo '<div class="result result-' . $result[0] . '">' . htmlspecialchars($result[1]) . '</div>';
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Quick Fix Panel -->
|
||||||
|
<div class="fix-box">
|
||||||
|
<h2>Quick Fix Panel</h2>
|
||||||
|
<p>Click buttons to automatically fix common issues:</p>
|
||||||
|
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="fix_action" value="create_htaccess">
|
||||||
|
<button type="submit" class="btn btn-primary" onclick="return confirm('Create/overwrite .htaccess?')">Create .htaccess</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="fix_action" value="create_config">
|
||||||
|
<button type="submit" class="btn btn-primary" onclick="return confirm('Create config.php from example?')">Create config.php</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="fix_action" value="fix_permissions">
|
||||||
|
<button type="submit" class="btn btn-warning">Fix All Permissions</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="fix_action" value="fix_auth_config_perms">
|
||||||
|
<button type="submit" class="btn btn-warning">Secure auth_config.php</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="fix_action" value="reset_user">
|
||||||
|
<button type="submit" class="btn btn-danger" onclick="return confirm('DELETE all users and start fresh?')">Reset Users</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="fix_action" value="delete_debug">
|
||||||
|
<button type="submit" class="btn btn-success" onclick="return confirm('Delete debug.php and go to main site?')">Delete debug.php & Go to Site</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// PHP Version
|
||||||
|
echo "<h2>1. PHP Version</h2>";
|
||||||
|
echo "<p>PHP Version: <strong>" . phpversion() . "</strong></p>";
|
||||||
|
|
||||||
|
if (version_compare(PHP_VERSION, '7.2.0', '<')) {
|
||||||
|
echo "<p style='color:red'>WARNING: PHP 7.2+ required!</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:green'>OK: PHP version is compatible</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required extensions
|
||||||
|
echo "<h2>2. Required Extensions</h2>";
|
||||||
|
$extensions = array('curl', 'json', 'mbstring', 'session');
|
||||||
|
foreach ($extensions as $ext) {
|
||||||
|
if (extension_loaded($ext)) {
|
||||||
|
echo "<p style='color:green'>OK: {$ext}</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>MISSING: {$ext}</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCMath (for base58 decoding)
|
||||||
|
echo "<p>";
|
||||||
|
if (function_exists('bcmul')) {
|
||||||
|
echo "<span style='color:green'>OK: bcmath</span>";
|
||||||
|
} else {
|
||||||
|
echo "<span style='color:orange'>WARNING: bcmath not available (short URLs won't work)</span>";
|
||||||
|
}
|
||||||
|
echo "</p>";
|
||||||
|
|
||||||
|
// Password algorithms
|
||||||
|
echo "<h2>3. Password Hashing</h2>";
|
||||||
|
if (defined('PASSWORD_ARGON2ID')) {
|
||||||
|
echo "<p style='color:green'>OK: Argon2ID available</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>INFO: Argon2ID not available, using bcrypt (OK)</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config file
|
||||||
|
echo "<h2>4. Configuration</h2>";
|
||||||
|
if (file_exists(__DIR__ . '/config.php')) {
|
||||||
|
echo "<p style='color:green'>OK: config.php exists</p>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$config = require __DIR__ . '/config.php';
|
||||||
|
echo "<p style='color:green'>OK: config.php is valid PHP</p>";
|
||||||
|
|
||||||
|
if (!empty($config['flickr']['api_key'])) {
|
||||||
|
echo "<p style='color:green'>OK: Flickr API key set</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>INFO: Flickr API key not set</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($config['telegram']['bot_token'])) {
|
||||||
|
echo "<p style='color:green'>OK: Telegram bot token set</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>INFO: Telegram bot token not set</p>";
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo "<p style='color:red'>ERROR in config.php: " . htmlspecialchars($e->getMessage()) . "</p>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>MISSING: config.php — <strong>use Quick Fix above!</strong></p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writable directories
|
||||||
|
echo "<h2>5. File Permissions</h2>";
|
||||||
|
if (is_writable(__DIR__)) {
|
||||||
|
echo "<p style='color:green'>OK: Root directory is writable</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>ERROR: Root directory is not writable (needed for auth_config.php)</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test class loading
|
||||||
|
echo "<h2>6. Class Loading Test</h2>";
|
||||||
|
|
||||||
|
$classes = array('Auth', 'FlickrParser', 'FormatGenerator', 'FlickrAPI', 'TelegramBot');
|
||||||
|
foreach ($classes as $class) {
|
||||||
|
$file = __DIR__ . '/classes/' . $class . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
try {
|
||||||
|
require_once $file;
|
||||||
|
if (class_exists($class)) {
|
||||||
|
echo "<p style='color:green'>OK: {$class}</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>ERROR: {$class} - file loaded but class not found</p>";
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo "<p style='color:red'>ERROR loading {$class}: " . htmlspecialchars($e->getMessage()) . "</p>";
|
||||||
|
echo "<pre>" . htmlspecialchars($e->getTraceAsString()) . "</pre>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>MISSING: {$file}</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Auth instantiation
|
||||||
|
echo "<h2>7. Auth System Test</h2>";
|
||||||
|
try {
|
||||||
|
$auth = new Auth();
|
||||||
|
echo "<p style='color:green'>OK: Auth class instantiated</p>";
|
||||||
|
|
||||||
|
if ($auth->hasUsers()) {
|
||||||
|
echo "<p style='color:green'>OK: Users exist, login page should work</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>INFO: No users yet, setup.php should appear</p>";
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo "<p style='color:red'>ERROR: " . htmlspecialchars($e->getMessage()) . "</p>";
|
||||||
|
echo "<pre>" . htmlspecialchars($e->getTraceAsString()) . "</pre>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security checks
|
||||||
|
echo "<h2>8. Security Checks</h2>";
|
||||||
|
|
||||||
|
// HTTPS
|
||||||
|
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
||||||
|
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') ||
|
||||||
|
(!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443);
|
||||||
|
if ($isHttps) {
|
||||||
|
echo "<p style='color:green'>OK: HTTPS enabled</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>WARNING: HTTPS not detected (recommended for production)</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// .htaccess exists
|
||||||
|
if (file_exists(__DIR__ . '/.htaccess')) {
|
||||||
|
echo "<p style='color:green'>OK: .htaccess exists</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>WARNING: .htaccess missing — <strong>use Quick Fix above!</strong></p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAK DETECTION ==========
|
||||||
|
echo "<h3>Leak Detection (API Keys & Credentials)</h3>";
|
||||||
|
|
||||||
|
// Check if sensitive files are accessible via web
|
||||||
|
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') .
|
||||||
|
'://' . $_SERVER['HTTP_HOST'] . dirname($_SERVER['REQUEST_URI']);
|
||||||
|
|
||||||
|
$sensitiveFiles = array(
|
||||||
|
'config.php' => 'API Keys (Flickr, Telegram)',
|
||||||
|
'auth_config.php' => 'User passwords (hashed)',
|
||||||
|
'config.example.php' => 'Config template',
|
||||||
|
'classes/Auth.php' => 'Auth class source',
|
||||||
|
'classes/FlickrAPI.php' => 'Flickr API class',
|
||||||
|
'classes/TelegramBot.php' => 'Telegram Bot class',
|
||||||
|
'.env' => 'Environment file',
|
||||||
|
'.git/config' => 'Git config',
|
||||||
|
'composer.json' => 'Dependencies',
|
||||||
|
'error_log' => 'Error log',
|
||||||
|
'debug.log' => 'Debug log',
|
||||||
|
);
|
||||||
|
|
||||||
|
$leaksFound = 0;
|
||||||
|
foreach ($sensitiveFiles as $file => $desc) {
|
||||||
|
$testUrl = rtrim($baseUrl, '/') . '/' . $file;
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, array(
|
||||||
|
CURLOPT_URL => $testUrl,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
));
|
||||||
|
$content = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode == 403 || $httpCode == 404 || $httpCode == 500) {
|
||||||
|
echo "<p style='color:green'>OK: {$file} is protected (HTTP {$httpCode})</p>";
|
||||||
|
} elseif ($httpCode == 200) {
|
||||||
|
// Check if content contains sensitive data
|
||||||
|
$hasSensitiveData = false;
|
||||||
|
if (strpos($content, 'api_key') !== false ||
|
||||||
|
strpos($content, 'api_secret') !== false ||
|
||||||
|
strpos($content, 'bot_token') !== false ||
|
||||||
|
strpos($content, 'password_hash') !== false ||
|
||||||
|
strpos($content, 'password') !== false) {
|
||||||
|
$hasSensitiveData = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasSensitiveData) {
|
||||||
|
echo "<p style='color:red'><strong>CRITICAL LEAK:</strong> {$file} is ACCESSIBLE and contains sensitive data! ({$desc})</p>";
|
||||||
|
$leaksFound++;
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>WARNING: {$file} is accessible (HTTP 200) - {$desc}</p>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>INFO: {$file} returned HTTP {$httpCode}</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common info disclosure files
|
||||||
|
echo "<h3>Information Disclosure Check</h3>";
|
||||||
|
$infoFiles = array(
|
||||||
|
'phpinfo.php' => 'PHP Info page',
|
||||||
|
'info.php' => 'PHP Info page',
|
||||||
|
'test.php' => 'Test file',
|
||||||
|
'readme.md' => 'Readme file',
|
||||||
|
'README.md' => 'Readme file',
|
||||||
|
'CHANGELOG.md' => 'Changelog',
|
||||||
|
'.htpasswd' => 'Password file',
|
||||||
|
'backup.sql' => 'Database backup',
|
||||||
|
'dump.sql' => 'Database dump',
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($infoFiles as $file => $desc) {
|
||||||
|
$path = __DIR__ . '/' . $file;
|
||||||
|
if (file_exists($path)) {
|
||||||
|
echo "<p style='color:orange'>WARNING: {$file} exists - consider removing ({$desc})</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if secrets are exposed in JS files
|
||||||
|
echo "<h3>Secret Exposure in Public Files</h3>";
|
||||||
|
$publicFiles = array(
|
||||||
|
'js/app.js',
|
||||||
|
'css/style.css',
|
||||||
|
);
|
||||||
|
|
||||||
|
$secretPatterns = array(
|
||||||
|
'/api[_-]?key\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'API Key',
|
||||||
|
'/bot[_-]?token\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'Bot Token',
|
||||||
|
'/password\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'Password',
|
||||||
|
'/secret\s*[:=]\s*[\'"][^\'"]+[\'"]/i' => 'Secret',
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($publicFiles as $file) {
|
||||||
|
$path = __DIR__ . '/' . $file;
|
||||||
|
if (file_exists($path)) {
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
$foundSecrets = array();
|
||||||
|
|
||||||
|
foreach ($secretPatterns as $pattern => $type) {
|
||||||
|
if (preg_match($pattern, $content)) {
|
||||||
|
$foundSecrets[] = $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($foundSecrets)) {
|
||||||
|
echo "<p style='color:red'><strong>DANGER:</strong> {$file} may contain: " . implode(', ', $foundSecrets) . "</p>";
|
||||||
|
$leaksFound++;
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:green'>OK: {$file} - no secrets found</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config.php for exposed secrets (verify it's not outputting)
|
||||||
|
if (file_exists(__DIR__ . '/config.php')) {
|
||||||
|
$configContent = file_get_contents(__DIR__ . '/config.php');
|
||||||
|
|
||||||
|
// Check if config has echo/print statements
|
||||||
|
if (preg_match('/(echo|print|var_dump|print_r)\s*\(/i', $configContent)) {
|
||||||
|
echo "<p style='color:red'><strong>DANGER:</strong> config.php contains output statements!</p>";
|
||||||
|
$leaksFound++;
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:green'>OK: config.php has no output statements</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config returns array (proper format)
|
||||||
|
if (strpos($configContent, 'return') !== false) {
|
||||||
|
echo "<p style='color:green'>OK: config.php uses return statement (good)</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>WARNING: config.php may not return array properly</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if ($leaksFound > 0) {
|
||||||
|
echo "<div style='background:#ffebee;border:2px solid #f44336;padding:15px;margin:15px 0;border-radius:5px;'>";
|
||||||
|
echo "<strong style='color:#c62828;'>SECURITY ALERT: {$leaksFound} potential leak(s) detected!</strong>";
|
||||||
|
echo "<p>Use Quick Fix buttons above or manually fix the issues.</p>";
|
||||||
|
echo "</div>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:green'><strong>No critical leaks detected.</strong></p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// File permissions
|
||||||
|
echo "<h3>File Permissions</h3>";
|
||||||
|
$checkPerms = array(
|
||||||
|
'config.php' => '0600 or 0644',
|
||||||
|
'auth_config.php' => '0600',
|
||||||
|
'.htaccess' => '0644',
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($checkPerms as $file => $recommended) {
|
||||||
|
$path = __DIR__ . '/' . $file;
|
||||||
|
if (file_exists($path)) {
|
||||||
|
$perms = substr(sprintf('%o', fileperms($path)), -4);
|
||||||
|
$worldReadable = (fileperms($path) & 0x0004);
|
||||||
|
|
||||||
|
if ($file === 'auth_config.php' && $worldReadable) {
|
||||||
|
echo "<p style='color:orange'>WARNING: {$file} is world-readable ({$perms}), recommended: {$recommended}</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:green'>OK: {$file} permissions: {$perms}</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHP security settings
|
||||||
|
echo "<h3>PHP Security Settings</h3>";
|
||||||
|
echo "<p style='color:#666'><em>These are hosting settings - may not be changeable on shared hosting</em></p>";
|
||||||
|
|
||||||
|
$securitySettings = array(
|
||||||
|
'expose_php' => array('recommended' => '0', 'desc' => 'Hide PHP version'),
|
||||||
|
'display_errors' => array('recommended' => '0', 'desc' => 'Hide errors in production'),
|
||||||
|
'allow_url_include' => array('recommended' => '0', 'desc' => 'Prevent remote file inclusion'),
|
||||||
|
'session.cookie_httponly' => array('recommended' => '1', 'desc' => 'Protect session cookie'),
|
||||||
|
'session.cookie_secure' => array('recommended' => '1', 'desc' => 'HTTPS-only cookies'),
|
||||||
|
'session.use_strict_mode' => array('recommended' => '1', 'desc' => 'Strict session mode'),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($securitySettings as $setting => $info) {
|
||||||
|
$value = ini_get($setting);
|
||||||
|
$valueStr = ($value === '' || $value === '0' || $value === false) ? '0' : '1';
|
||||||
|
|
||||||
|
if ($valueStr === $info['recommended']) {
|
||||||
|
echo "<p style='color:green'>OK: {$setting} = {$valueStr} ({$info['desc']})</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>INFO: {$setting} = {$valueStr}, recommended: {$info['recommended']} ({$info['desc']})</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cryptographic Functions
|
||||||
|
echo "<h3>Cryptographic Functions</h3>";
|
||||||
|
if (function_exists('random_bytes')) {
|
||||||
|
try {
|
||||||
|
$test = random_bytes(32);
|
||||||
|
echo "<p style='color:green'>OK: random_bytes() works</p>";
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "<p style='color:red'>ERROR: random_bytes() failed</p>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>WARNING: random_bytes() not available</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('openssl_random_pseudo_bytes')) {
|
||||||
|
echo "<p style='color:green'>OK: openssl_random_pseudo_bytes() available</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:orange'>WARNING: openssl_random_pseudo_bytes() not available</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('password_hash')) {
|
||||||
|
echo "<p style='color:green'>OK: password_hash() available</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>ERROR: password_hash() not available!</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server info (be careful not to expose too much)
|
||||||
|
echo "<h2>9. Server Info</h2>";
|
||||||
|
echo "<p>Server software: <code>" . htmlspecialchars(isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'Unknown') . "</code></p>";
|
||||||
|
echo "<p>Document root: <code>" . htmlspecialchars(isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : 'Unknown') . "</code></p>";
|
||||||
|
echo "<p>Script path: <code>" . htmlspecialchars(__DIR__) . "</code></p>";
|
||||||
|
|
||||||
|
echo "<hr>";
|
||||||
|
echo "<p><strong>If all green:</strong> Use <strong>'Delete debug.php & Go to Site'</strong> button above</p>";
|
||||||
|
echo "<p><strong>If errors:</strong> Use Quick Fix buttons, then refresh this page</p>";
|
||||||
|
echo "<p style='color:red'><strong>IMPORTANT: Delete this file after debugging!</strong></p>";
|
||||||
|
?>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Debug script to check Flickr URL formats and file sizes
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/classes/FlickrAPI.php';
|
||||||
|
// Try config.php first, fall back to config.example.php for testing
|
||||||
|
$configFile = __DIR__ . '/config.php';
|
||||||
|
$config = require $configFile;
|
||||||
|
if (empty($config['flickr']['api_key']) && file_exists(__DIR__ . '/config.example.php')) {
|
||||||
|
$config = require __DIR__ . '/config.example.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
die("Flickr API key not configured\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check URL status and file size
|
||||||
|
function checkUrl($url) {
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_NOBODY => true, // HEAD request
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0',
|
||||||
|
CURLOPT_HTTPHEADER => ['Referer: https://www.flickr.com/'],
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$sizeStr = $size > 0 ? formatSize($size) : '?';
|
||||||
|
return ['status' => $httpCode, 'size' => $sizeStr, 'bytes' => $size];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize($bytes) {
|
||||||
|
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
|
||||||
|
if ($bytes >= 1024) return round($bytes / 1024) . ' KB';
|
||||||
|
return $bytes . ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = new FlickrAPI(
|
||||||
|
$config['flickr']['api_key'],
|
||||||
|
$config['flickr']['api_secret'] ?? '',
|
||||||
|
$config['flickr_user_id'] ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "=== Testing Flickr URL Formats ===\n\n";
|
||||||
|
|
||||||
|
// Get one album
|
||||||
|
$albums = $flickr->getPhotosets(1, 1);
|
||||||
|
if (empty($albums)) {
|
||||||
|
die("No albums found\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$albumId = $albums[0]['id'];
|
||||||
|
$albumTitle = $albums[0]['title']['_content'] ?? 'Unknown';
|
||||||
|
echo "Album: $albumTitle (ID: $albumId)\n\n";
|
||||||
|
|
||||||
|
// Get photos from album
|
||||||
|
$result = $flickr->getPhotosetPhotos($albumId, 1, 3);
|
||||||
|
|
||||||
|
if (empty($result['photos'])) {
|
||||||
|
die("No photos in album\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result['photos'] as $i => $photo) {
|
||||||
|
echo "--- Photo " . ($i + 1) . " ---\n";
|
||||||
|
echo "ID: " . $photo['id'] . "\n";
|
||||||
|
echo "Title: " . $photo['title'] . "\n";
|
||||||
|
echo "Server: " . $photo['server'] . "\n";
|
||||||
|
echo "Secret: " . $photo['secret'] . "\n";
|
||||||
|
echo "Original Secret: " . $photo['original_secret'] . "\n";
|
||||||
|
echo "Original Format: " . $photo['original_format'] . "\n";
|
||||||
|
|
||||||
|
echo "\nURLs (with sizes):\n";
|
||||||
|
|
||||||
|
// Test each URL size
|
||||||
|
$sizes = ['medium640', 'large', 'large2048', 'original'];
|
||||||
|
foreach ($sizes as $size) {
|
||||||
|
$url = $photo['urls'][$size] ?? null;
|
||||||
|
if ($url) {
|
||||||
|
$info = checkUrl($url);
|
||||||
|
$status = $info['status'] == 200 ? '✓' : '✗ ' . $info['status'];
|
||||||
|
echo " $size: $status - {$info['size']}\n";
|
||||||
|
echo " $url\n";
|
||||||
|
} else {
|
||||||
|
echo " $size: (not available)\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If original secret differs from secret, note it
|
||||||
|
if ($photo['original_secret'] !== $photo['secret']) {
|
||||||
|
echo "\n *** Original secret is DIFFERENT - originals should be accessible ***\n";
|
||||||
|
} else {
|
||||||
|
echo "\n Note: Original secret = Secret (originals may not be enabled in Flickr settings)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Summary ===\n";
|
||||||
|
echo "If 'original' shows ✗ 403 or ✗ 404:\n";
|
||||||
|
echo " -> Enable original downloads in Flickr: Settings > Privacy > Allow downloads\n";
|
||||||
|
echo "If 'original' shows ✓ with large size:\n";
|
||||||
|
echo " -> Downloads should work at full quality!\n";
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Страница первоначальной настройки - создание администратора
|
||||||
|
*/
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/classes/Auth.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
|
||||||
|
// If users already exist, redirect to login
|
||||||
|
if ($auth->hasUsers()) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = false;
|
||||||
|
|
||||||
|
// Handle setup form submission
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (empty($username)) {
|
||||||
|
$error = 'Имя пользователя обязательно';
|
||||||
|
} elseif (strlen($username) < 3) {
|
||||||
|
$error = 'Имя пользователя должно быть не менее 3 символов';
|
||||||
|
} elseif (strlen($password) < 8) {
|
||||||
|
$error = 'Пароль должен быть не менее 8 символов';
|
||||||
|
} elseif ($password !== $confirmPassword) {
|
||||||
|
$error = 'Пароли не совпадают';
|
||||||
|
} else {
|
||||||
|
// Create user
|
||||||
|
if ($auth->createUser($username, $password)) {
|
||||||
|
$success = true;
|
||||||
|
} else {
|
||||||
|
$error = 'Не удалось создать пользователя';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF token
|
||||||
|
$csrfToken = bin2hex(random_bytes(32));
|
||||||
|
$_SESSION['csrf_token'] = $csrfToken;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Настройка - VH Posting System</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script>
|
||||||
|
// Apply saved theme immediately
|
||||||
|
(function() {
|
||||||
|
const theme = localStorage.getItem('theme') || 'light';
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>VH Posting System</h1>
|
||||||
|
<h2>Первоначальная настройка</h2>
|
||||||
|
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Администратор успешно создан!
|
||||||
|
<br><br>
|
||||||
|
<a href="login.php" class="btn btn-primary">Перейти ко входу</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="help-text">Создайте учётную запись администратора</p>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="setup.php">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= $csrfToken ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Имя пользователя:</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus
|
||||||
|
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
|
||||||
|
minlength="3">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль:</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8">
|
||||||
|
<span class="hint">Минимум 8 символов</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Подтвердите пароль:</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-block">Создать администратора</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Direct test of photo download - bypasses Auth
|
||||||
|
* Delete this file after testing!
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Test with one of the working URLs from debug output
|
||||||
|
$testUrl = $_GET['url'] ?? 'https://live.staticflickr.com/65535/54995003092_7cfd04e2ba_b.jpg';
|
||||||
|
|
||||||
|
echo "<h2>Testing Download: $testUrl</h2>";
|
||||||
|
|
||||||
|
// Fetch the image
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $testUrl,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 15,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: image/*, */*',
|
||||||
|
'Referer: https://www.flickr.com/',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$content = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
echo "<p>HTTP Status: <b>$httpCode</b></p>";
|
||||||
|
echo "<p>Content-Type: <b>$contentType</b></p>";
|
||||||
|
echo "<p>Content Size: <b>" . strlen($content) . "</b> bytes</p>";
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
echo "<p style='color:red'>cURL Error: $error</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check magic bytes
|
||||||
|
$magicBytes = substr($content, 0, 16);
|
||||||
|
echo "<p>First 16 bytes (hex): <code>" . bin2hex($magicBytes) . "</code></p>";
|
||||||
|
|
||||||
|
$isJpeg = (substr($content, 0, 2) === "\xFF\xD8");
|
||||||
|
$isPng = (substr($content, 0, 4) === "\x89PNG");
|
||||||
|
|
||||||
|
echo "<p>Is JPEG: <b>" . ($isJpeg ? 'YES' : 'NO') . "</b></p>";
|
||||||
|
echo "<p>Is PNG: <b>" . ($isPng ? 'YES' : 'NO') . "</b></p>";
|
||||||
|
|
||||||
|
if ($httpCode === 200 && ($isJpeg || $isPng)) {
|
||||||
|
echo "<h3 style='color:green'>✓ Image fetched successfully!</h3>";
|
||||||
|
|
||||||
|
// Save test file
|
||||||
|
$testFile = sys_get_temp_dir() . '/flickr_test_' . time() . '.jpg';
|
||||||
|
file_put_contents($testFile, $content);
|
||||||
|
|
||||||
|
// Verify with getimagesize
|
||||||
|
$imgInfo = @getimagesize($testFile);
|
||||||
|
if ($imgInfo) {
|
||||||
|
echo "<p>Image verified: <b>{$imgInfo[0]}x{$imgInfo[1]}</b> - {$imgInfo['mime']}</p>";
|
||||||
|
echo "<p>Test file saved to: $testFile</p>";
|
||||||
|
} else {
|
||||||
|
echo "<p style='color:red'>getimagesize() failed on saved file!</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
echo "<h3>Preview (base64):</h3>";
|
||||||
|
echo "<img src='data:image/jpeg;base64," . base64_encode($content) . "' style='max-width:500px'>";
|
||||||
|
|
||||||
|
// Download link
|
||||||
|
echo "<h3>Test Download:</h3>";
|
||||||
|
echo "<p><a href='?action=download&url=" . urlencode($testUrl) . "'>Click to test download</a></p>";
|
||||||
|
|
||||||
|
@unlink($testFile);
|
||||||
|
} else {
|
||||||
|
echo "<h3 style='color:red'>✗ Failed to fetch valid image</h3>";
|
||||||
|
if (strlen($content) < 500) {
|
||||||
|
echo "<p>Response content:</p><pre>" . htmlspecialchars($content) . "</pre>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle download action
|
||||||
|
if (($_GET['action'] ?? '') === 'download') {
|
||||||
|
$url = $_GET['url'] ?? '';
|
||||||
|
if (!$url) die('No URL');
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0',
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: image/*', 'Referer: https://www.flickr.com/'],
|
||||||
|
]);
|
||||||
|
$content = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
// Clear ALL output
|
||||||
|
while (ob_get_level()) ob_end_clean();
|
||||||
|
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Content-Length: ' . strlen($content));
|
||||||
|
header('Content-Disposition: attachment; filename="test_photo.jpg"');
|
||||||
|
echo $content;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
Binary file not shown.
+246
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Public Widget API for Flickr Photo Mosaic
|
||||||
|
* No authentication required - public photos only
|
||||||
|
*/
|
||||||
|
|
||||||
|
// CORS headers for WordPress access
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Handle preflight
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
$configFile = __DIR__ . '/config.php';
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
echo json_encode(['error' => 'Configuration not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$config = require $configFile;
|
||||||
|
|
||||||
|
// Autoload classes
|
||||||
|
spl_autoload_register(function ($class) {
|
||||||
|
$file = __DIR__ . '/classes/' . $class . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Widget settings file
|
||||||
|
$widgetSettingsFile = __DIR__ . '/data/widget_settings.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get widget settings
|
||||||
|
*/
|
||||||
|
function getWidgetSettings($file) {
|
||||||
|
if (file_exists($file)) {
|
||||||
|
return json_decode(file_get_contents($file), true) ?: [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'albums' => [],
|
||||||
|
'max_photos' => 30,
|
||||||
|
'cache_time' => 3600, // 1 hour
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create FlickrAPI instance
|
||||||
|
*/
|
||||||
|
function createFlickrAPI($config) {
|
||||||
|
$flickr = new FlickrAPI(
|
||||||
|
$config['flickr']['api_key'],
|
||||||
|
$config['flickr']['api_secret'] ?? '',
|
||||||
|
$config['flickr_user_id'] ?? ''
|
||||||
|
);
|
||||||
|
return $flickr;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
case 'get_photos':
|
||||||
|
// Public endpoint - returns photos for widget
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
echo json_encode(['error' => 'Flickr not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = getWidgetSettings($widgetSettingsFile);
|
||||||
|
|
||||||
|
if (!$settings['enabled']) {
|
||||||
|
echo json_encode(['error' => 'Widget disabled']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
$cacheFile = __DIR__ . '/data/widget_cache.json';
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
$cacheData = json_decode(file_get_contents($cacheFile), true);
|
||||||
|
if ($cacheData && isset($cacheData['timestamp'])) {
|
||||||
|
$cacheAge = time() - $cacheData['timestamp'];
|
||||||
|
if ($cacheAge < ($settings['cache_time'] ?? 3600)) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'photos' => $cacheData['photos'],
|
||||||
|
'cached' => true,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
$allPhotos = [];
|
||||||
|
$maxPhotos = $settings['max_photos'] ?? 30;
|
||||||
|
$selectedAlbums = $settings['albums'] ?? [];
|
||||||
|
|
||||||
|
if (empty($selectedAlbums)) {
|
||||||
|
// If no albums selected, get recent photos
|
||||||
|
$result = $flickr->getPhotos(1, $maxPhotos);
|
||||||
|
$allPhotos = $result['photos'] ?? [];
|
||||||
|
} else {
|
||||||
|
// Get photos from selected albums
|
||||||
|
$photosPerAlbum = max(5, ceil($maxPhotos / count($selectedAlbums)));
|
||||||
|
|
||||||
|
foreach ($selectedAlbums as $albumId) {
|
||||||
|
try {
|
||||||
|
$result = $flickr->getPhotosetPhotos($albumId, 1, $photosPerAlbum);
|
||||||
|
if (!empty($result['photos'])) {
|
||||||
|
$allPhotos = array_merge($allPhotos, $result['photos']);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Skip failed album
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle and limit
|
||||||
|
shuffle($allPhotos);
|
||||||
|
$allPhotos = array_slice($allPhotos, 0, $maxPhotos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplify photo data for widget
|
||||||
|
$widgetPhotos = array_map(function($photo) {
|
||||||
|
$urls = $photo['urls'] ?? [];
|
||||||
|
return [
|
||||||
|
'id' => $photo['id'],
|
||||||
|
'title' => $photo['title'] ?? '',
|
||||||
|
'thumb' => $urls['small'] ?? $urls['thumbnail'] ?? $urls['square'] ?? '',
|
||||||
|
'medium' => $urls['medium'] ?? $urls['medium640'] ?? $urls['small'] ?? '',
|
||||||
|
'large' => $urls['large'] ?? $urls['large2048'] ?? $urls['medium'] ?? '',
|
||||||
|
'page_url' => $photo['page_url'] ?? '',
|
||||||
|
];
|
||||||
|
}, $allPhotos);
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
$dataDir = __DIR__ . '/data';
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0755, true);
|
||||||
|
}
|
||||||
|
file_put_contents($cacheFile, json_encode([
|
||||||
|
'timestamp' => time(),
|
||||||
|
'photos' => $widgetPhotos,
|
||||||
|
]));
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'photos' => $widgetPhotos,
|
||||||
|
'cached' => false,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get_albums':
|
||||||
|
// For admin - list available albums
|
||||||
|
session_start();
|
||||||
|
$auth = new Auth();
|
||||||
|
if (!$auth->isAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($config['flickr']['api_key'])) {
|
||||||
|
echo json_encode(['error' => 'Flickr not configured']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flickr = createFlickrAPI($config);
|
||||||
|
$result = $flickr->getPhotosets(1, 100);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'albums' => $result['albums'],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get_settings':
|
||||||
|
// For admin - get widget settings
|
||||||
|
session_start();
|
||||||
|
$auth = new Auth();
|
||||||
|
if (!$auth->isAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = getWidgetSettings($widgetSettingsFile);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'settings' => $settings,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'save_settings':
|
||||||
|
// For admin - save widget settings
|
||||||
|
session_start();
|
||||||
|
$auth = new Auth();
|
||||||
|
if (!$auth->isAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$settings = [
|
||||||
|
'enabled' => $input['enabled'] ?? true,
|
||||||
|
'albums' => $input['albums'] ?? [],
|
||||||
|
'max_photos' => (int)($input['max_photos'] ?? 30),
|
||||||
|
'cache_time' => (int)($input['cache_time'] ?? 3600),
|
||||||
|
];
|
||||||
|
|
||||||
|
$dataDir = __DIR__ . '/data';
|
||||||
|
if (!is_dir($dataDir)) {
|
||||||
|
mkdir($dataDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache when settings change
|
||||||
|
$cacheFile = __DIR__ . '/data/widget_cache.json';
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
unlink($cacheFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($widgetSettingsFile, json_encode($settings, JSON_PRETTY_PRINT))) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Settings saved']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Failed to save settings']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo json_encode(['error' => 'Unknown action', 'available' => ['get_photos', 'get_albums', 'get_settings', 'save_settings']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* VH Flickr Mosaic - Styles
|
||||||
|
* Beautiful photo mosaic with fade animations
|
||||||
|
*/
|
||||||
|
|
||||||
|
.vh-flickr-mosaic {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.03) 100%);
|
||||||
|
padding: 20px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-container {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: opacity 0.8s ease-in-out;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade animation for image swap */
|
||||||
|
.vh-mosaic-item img.vh-fading-out {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item img.vh-fading-in {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
animation: vhFadeIn 0.8s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vhFadeIn {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.vh-mosaic-item.vh-loading {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: vhShimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vhShimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title overlay on hover */
|
||||||
|
.vh-mosaic-item .vh-photo-title {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item:hover .vh-photo-title {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vh-flickr-mosaic {
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-container {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.vh-mosaic-container {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item .vh-photo-title {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.vh-flickr-mosaic {
|
||||||
|
background: linear-gradient(180deg, transparent 0%, rgba(255,255,255,0.03) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item.vh-loading {
|
||||||
|
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce motion for accessibility */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vh-mosaic-item,
|
||||||
|
.vh-mosaic-item img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item img.vh-fading-in {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-mosaic-item.vh-loading {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* VH Flickr Mosaic - JavaScript
|
||||||
|
* Beautiful photo mosaic with fade animations
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class VHFlickrMosaic {
|
||||||
|
constructor(container) {
|
||||||
|
this.container = container;
|
||||||
|
this.mosaicEl = container.querySelector('.vh-mosaic-container');
|
||||||
|
this.photos = [];
|
||||||
|
this.displayedPhotos = [];
|
||||||
|
this.rows = parseInt(container.dataset.rows) || vhMosaicConfig.rows || 2;
|
||||||
|
this.photoSize = parseInt(container.dataset.size) || vhMosaicConfig.photoSize || 150;
|
||||||
|
this.animationSpeed = parseFloat(container.dataset.speed) || vhMosaicConfig.animationSpeed || 5;
|
||||||
|
this.apiUrl = vhMosaicConfig.apiUrl;
|
||||||
|
this.animationInterval = null;
|
||||||
|
this.isVisible = false;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Set up intersection observer for lazy loading
|
||||||
|
this.setupVisibilityObserver();
|
||||||
|
|
||||||
|
// Load photos
|
||||||
|
await this.loadPhotos();
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
// Start animation when visible
|
||||||
|
if (this.isVisible) {
|
||||||
|
this.startAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupVisibilityObserver() {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
this.isVisible = entry.isIntersecting;
|
||||||
|
if (this.isVisible && this.photos.length > 0) {
|
||||||
|
this.startAnimation();
|
||||||
|
} else {
|
||||||
|
this.stopAnimation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
|
observer.observe(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPhotos() {
|
||||||
|
if (!this.apiUrl) {
|
||||||
|
console.error('VH Flickr Mosaic: API URL not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiUrl);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.photos) {
|
||||||
|
this.photos = data.photos;
|
||||||
|
} else {
|
||||||
|
console.error('VH Flickr Mosaic: Failed to load photos', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('VH Flickr Mosaic: API error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateGrid() {
|
||||||
|
const containerWidth = this.mosaicEl.offsetWidth || window.innerWidth;
|
||||||
|
const cols = Math.floor(containerWidth / (this.photoSize + 8)); // 8px gap
|
||||||
|
return {
|
||||||
|
cols: Math.max(cols, 3),
|
||||||
|
total: Math.max(cols, 3) * this.rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.photos.length === 0) {
|
||||||
|
this.mosaicEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cols, total } = this.calculateGrid();
|
||||||
|
|
||||||
|
// Set grid columns
|
||||||
|
this.mosaicEl.style.gridTemplateColumns = `repeat(${cols}, ${this.photoSize}px)`;
|
||||||
|
|
||||||
|
// Select random photos for display
|
||||||
|
this.displayedPhotos = this.getRandomPhotos(total);
|
||||||
|
|
||||||
|
// Create HTML
|
||||||
|
this.mosaicEl.innerHTML = this.displayedPhotos.map((photo, index) => `
|
||||||
|
<div class="vh-mosaic-item" data-index="${index}" style="width:${this.photoSize}px;height:${this.photoSize}px;">
|
||||||
|
<a href="${photo.page_url || '#'}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="${photo.medium || photo.thumb}" alt="${this.escapeHtml(photo.title || '')}" loading="lazy">
|
||||||
|
${photo.title ? `<span class="vh-photo-title">${this.escapeHtml(photo.title)}</span>` : ''}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
getRandomPhotos(count) {
|
||||||
|
const shuffled = [...this.photos].sort(() => Math.random() - 0.5);
|
||||||
|
return shuffled.slice(0, Math.min(count, shuffled.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
startAnimation() {
|
||||||
|
if (this.animationInterval) return;
|
||||||
|
if (this.photos.length <= this.displayedPhotos.length) return;
|
||||||
|
|
||||||
|
this.animationInterval = setInterval(() => {
|
||||||
|
this.swapRandomPhoto();
|
||||||
|
}, this.animationSpeed * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAnimation() {
|
||||||
|
if (this.animationInterval) {
|
||||||
|
clearInterval(this.animationInterval);
|
||||||
|
this.animationInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
swapRandomPhoto() {
|
||||||
|
if (!this.isVisible || this.photos.length === 0) return;
|
||||||
|
|
||||||
|
const items = this.mosaicEl.querySelectorAll('.vh-mosaic-item');
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
// Pick random item to swap
|
||||||
|
const randomIndex = Math.floor(Math.random() * items.length);
|
||||||
|
const item = items[randomIndex];
|
||||||
|
|
||||||
|
// Find a photo not currently displayed
|
||||||
|
const currentIds = this.displayedPhotos.map(p => p.id);
|
||||||
|
const availablePhotos = this.photos.filter(p => !currentIds.includes(p.id));
|
||||||
|
|
||||||
|
if (availablePhotos.length === 0) return;
|
||||||
|
|
||||||
|
const newPhoto = availablePhotos[Math.floor(Math.random() * availablePhotos.length)];
|
||||||
|
|
||||||
|
// Animate the swap
|
||||||
|
this.animatePhotoSwap(item, newPhoto, randomIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
animatePhotoSwap(item, newPhoto, index) {
|
||||||
|
const oldImg = item.querySelector('img');
|
||||||
|
const link = item.querySelector('a');
|
||||||
|
|
||||||
|
if (!oldImg || !link) return;
|
||||||
|
|
||||||
|
// Create new image
|
||||||
|
const newImg = document.createElement('img');
|
||||||
|
newImg.src = newPhoto.medium || newPhoto.thumb;
|
||||||
|
newImg.alt = newPhoto.title || '';
|
||||||
|
newImg.className = 'vh-fading-in';
|
||||||
|
newImg.loading = 'lazy';
|
||||||
|
|
||||||
|
// Start fade out of old image
|
||||||
|
oldImg.classList.add('vh-fading-out');
|
||||||
|
|
||||||
|
// Add new image
|
||||||
|
link.appendChild(newImg);
|
||||||
|
|
||||||
|
// Update link href
|
||||||
|
link.href = newPhoto.page_url || '#';
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
let titleEl = item.querySelector('.vh-photo-title');
|
||||||
|
if (newPhoto.title) {
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = newPhoto.title;
|
||||||
|
} else {
|
||||||
|
titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'vh-photo-title';
|
||||||
|
titleEl.textContent = newPhoto.title;
|
||||||
|
link.appendChild(titleEl);
|
||||||
|
}
|
||||||
|
} else if (titleEl) {
|
||||||
|
titleEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After animation, clean up
|
||||||
|
setTimeout(() => {
|
||||||
|
oldImg.remove();
|
||||||
|
newImg.classList.remove('vh-fading-in');
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
// Update displayed photos array
|
||||||
|
this.displayedPhotos[index] = newPhoto;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all mosaics on page
|
||||||
|
function initMosaics() {
|
||||||
|
document.querySelectorAll('.vh-flickr-mosaic').forEach(container => {
|
||||||
|
new VHFlickrMosaic(container);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initMosaics);
|
||||||
|
} else {
|
||||||
|
initMosaics();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
let resizeTimeout;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(() => {
|
||||||
|
document.querySelectorAll('.vh-flickr-mosaic').forEach(container => {
|
||||||
|
const mosaic = container._vhMosaic;
|
||||||
|
if (mosaic) {
|
||||||
|
mosaic.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: VH Flickr Mosaic
|
||||||
|
* Plugin URI: https://github.com/vesnushka/vh-flickr-mosaic
|
||||||
|
* Description: Beautiful photo mosaic widget with fade animations, powered by VH Posting System
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Author: VH Posting System
|
||||||
|
* License: GPL v2 or later
|
||||||
|
* Text Domain: vh-flickr-mosaic
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VH_Flickr_Mosaic {
|
||||||
|
|
||||||
|
private static $instance = null;
|
||||||
|
private $options;
|
||||||
|
|
||||||
|
public static function get_instance() {
|
||||||
|
if (null === self::$instance) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function __construct() {
|
||||||
|
$this->options = get_option('vh_flickr_mosaic_options', [
|
||||||
|
'api_url' => '',
|
||||||
|
'position' => 'footer',
|
||||||
|
'rows' => 2,
|
||||||
|
'photo_size' => 150,
|
||||||
|
'animation_speed' => 5,
|
||||||
|
'enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
add_action('admin_menu', [$this, 'add_admin_menu']);
|
||||||
|
add_action('admin_init', [$this, 'settings_init']);
|
||||||
|
add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']);
|
||||||
|
add_action('wp_footer', [$this, 'render_mosaic']);
|
||||||
|
add_shortcode('flickr_mosaic', [$this, 'shortcode_mosaic']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add_admin_menu() {
|
||||||
|
add_options_page(
|
||||||
|
'VH Flickr Mosaic',
|
||||||
|
'VH Flickr Mosaic',
|
||||||
|
'manage_options',
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
[$this, 'options_page']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings_init() {
|
||||||
|
register_setting('vh_flickr_mosaic', 'vh_flickr_mosaic_options');
|
||||||
|
|
||||||
|
add_settings_section(
|
||||||
|
'vh_flickr_mosaic_section',
|
||||||
|
__('Настройки мозаики', 'vh-flickr-mosaic'),
|
||||||
|
null,
|
||||||
|
'vh-flickr-mosaic'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'api_url',
|
||||||
|
__('API URL', 'vh-flickr-mosaic'),
|
||||||
|
[$this, 'api_url_render'],
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
'vh_flickr_mosaic_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'enabled',
|
||||||
|
__('Включить', 'vh-flickr-mosaic'),
|
||||||
|
[$this, 'enabled_render'],
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
'vh_flickr_mosaic_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'position',
|
||||||
|
__('Позиция', 'vh-flickr-mosaic'),
|
||||||
|
[$this, 'position_render'],
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
'vh_flickr_mosaic_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'rows',
|
||||||
|
__('Количество рядов', 'vh-flickr-mosaic'),
|
||||||
|
[$this, 'rows_render'],
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
'vh_flickr_mosaic_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'photo_size',
|
||||||
|
__('Размер фото (px)', 'vh-flickr-mosaic'),
|
||||||
|
[$this, 'photo_size_render'],
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
'vh_flickr_mosaic_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'animation_speed',
|
||||||
|
__('Скорость анимации (сек)', 'vh-flickr-mosaic'),
|
||||||
|
[$this, 'animation_speed_render'],
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
'vh_flickr_mosaic_section'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function api_url_render() {
|
||||||
|
?>
|
||||||
|
<input type='url' name='vh_flickr_mosaic_options[api_url]'
|
||||||
|
value='<?php echo esc_attr($this->options['api_url'] ?? ''); ?>'
|
||||||
|
class='regular-text' placeholder='https://your-site.com/vh/widget_api.php?action=get_photos'>
|
||||||
|
<p class="description"><?php _e('URL API из VH Posting System (вкладка Виджет)', 'vh-flickr-mosaic'); ?></p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enabled_render() {
|
||||||
|
?>
|
||||||
|
<label>
|
||||||
|
<input type='checkbox' name='vh_flickr_mosaic_options[enabled]'
|
||||||
|
<?php checked($this->options['enabled'] ?? true); ?> value='1'>
|
||||||
|
<?php _e('Показывать мозаику', 'vh-flickr-mosaic'); ?>
|
||||||
|
</label>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function position_render() {
|
||||||
|
?>
|
||||||
|
<select name='vh_flickr_mosaic_options[position]'>
|
||||||
|
<option value='footer' <?php selected($this->options['position'] ?? 'footer', 'footer'); ?>>
|
||||||
|
<?php _e('В футере', 'vh-flickr-mosaic'); ?>
|
||||||
|
</option>
|
||||||
|
<option value='shortcode' <?php selected($this->options['position'] ?? 'footer', 'shortcode'); ?>>
|
||||||
|
<?php _e('Только шорткод [flickr_mosaic]', 'vh-flickr-mosaic'); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rows_render() {
|
||||||
|
?>
|
||||||
|
<input type='number' name='vh_flickr_mosaic_options[rows]'
|
||||||
|
value='<?php echo esc_attr($this->options['rows'] ?? 2); ?>'
|
||||||
|
min='1' max='5' style='width: 60px;'>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function photo_size_render() {
|
||||||
|
?>
|
||||||
|
<input type='number' name='vh_flickr_mosaic_options[photo_size]'
|
||||||
|
value='<?php echo esc_attr($this->options['photo_size'] ?? 150); ?>'
|
||||||
|
min='80' max='300' style='width: 80px;'>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function animation_speed_render() {
|
||||||
|
?>
|
||||||
|
<input type='number' name='vh_flickr_mosaic_options[animation_speed]'
|
||||||
|
value='<?php echo esc_attr($this->options['animation_speed'] ?? 5); ?>'
|
||||||
|
min='2' max='15' step='0.5' style='width: 80px;'>
|
||||||
|
<p class="description"><?php _e('Время между сменой фото', 'vh-flickr-mosaic'); ?></p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function options_page() {
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php _e('VH Flickr Mosaic', 'vh-flickr-mosaic'); ?></h1>
|
||||||
|
<form action='options.php' method='post'>
|
||||||
|
<?php
|
||||||
|
settings_fields('vh_flickr_mosaic');
|
||||||
|
do_settings_sections('vh-flickr-mosaic');
|
||||||
|
submit_button();
|
||||||
|
?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2><?php _e('Использование', 'vh-flickr-mosaic'); ?></h2>
|
||||||
|
<p><?php _e('Используйте шорткод для вставки мозаики в любое место:', 'vh-flickr-mosaic'); ?></p>
|
||||||
|
<code>[flickr_mosaic]</code>
|
||||||
|
<p><?php _e('С параметрами:', 'vh-flickr-mosaic'); ?></p>
|
||||||
|
<code>[flickr_mosaic rows="3" size="120" speed="4"]</code>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2><?php _e('Предпросмотр', 'vh-flickr-mosaic'); ?></h2>
|
||||||
|
<div id="vh-mosaic-preview">
|
||||||
|
<button type="button" class="button" onclick="vhMosaicPreview()">
|
||||||
|
<?php _e('Загрузить предпросмотр', 'vh-flickr-mosaic'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function vhMosaicPreview() {
|
||||||
|
const apiUrl = document.querySelector('input[name="vh_flickr_mosaic_options[api_url]"]').value;
|
||||||
|
if (!apiUrl) {
|
||||||
|
alert('Укажите API URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = document.getElementById('vh-mosaic-preview');
|
||||||
|
preview.innerHTML = '<p>Загрузка...</p>';
|
||||||
|
|
||||||
|
fetch(apiUrl)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success && data.photos) {
|
||||||
|
let html = '<div style="display:flex;flex-wrap:wrap;gap:8px;max-width:600px;">';
|
||||||
|
data.photos.slice(0, 12).forEach(photo => {
|
||||||
|
html += `<img src="${photo.thumb}" style="width:80px;height:80px;object-fit:cover;border-radius:4px;">`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
html += `<p>Загружено ${data.photos.length} фото</p>`;
|
||||||
|
preview.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = '<p style="color:red;">Ошибка: ' + (data.error || 'Неизвестная ошибка') + '</p>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
preview.innerHTML = '<p style="color:red;">Ошибка подключения: ' + err.message + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue_scripts() {
|
||||||
|
if (empty($this->options['enabled']) || empty($this->options['api_url'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_enqueue_style(
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
plugin_dir_url(__FILE__) . 'assets/css/mosaic.css',
|
||||||
|
[],
|
||||||
|
'1.0.0'
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'vh-flickr-mosaic',
|
||||||
|
plugin_dir_url(__FILE__) . 'assets/js/mosaic.js',
|
||||||
|
[],
|
||||||
|
'1.0.0',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_localize_script('vh-flickr-mosaic', 'vhMosaicConfig', [
|
||||||
|
'apiUrl' => $this->options['api_url'],
|
||||||
|
'rows' => intval($this->options['rows'] ?? 2),
|
||||||
|
'photoSize' => intval($this->options['photo_size'] ?? 150),
|
||||||
|
'animationSpeed' => floatval($this->options['animation_speed'] ?? 5),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_mosaic() {
|
||||||
|
if (empty($this->options['enabled']) || empty($this->options['api_url'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($this->options['position'] ?? 'footer') !== 'footer') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $this->get_mosaic_html();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shortcode_mosaic($atts) {
|
||||||
|
if (empty($this->options['api_url'])) {
|
||||||
|
return '<!-- VH Flickr Mosaic: API URL not configured -->';
|
||||||
|
}
|
||||||
|
|
||||||
|
$atts = shortcode_atts([
|
||||||
|
'rows' => $this->options['rows'] ?? 2,
|
||||||
|
'size' => $this->options['photo_size'] ?? 150,
|
||||||
|
'speed' => $this->options['animation_speed'] ?? 5,
|
||||||
|
], $atts);
|
||||||
|
|
||||||
|
return $this->get_mosaic_html($atts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_mosaic_html($atts = []) {
|
||||||
|
$rows = intval($atts['rows'] ?? $this->options['rows'] ?? 2);
|
||||||
|
$size = intval($atts['size'] ?? $this->options['photo_size'] ?? 150);
|
||||||
|
$speed = floatval($atts['speed'] ?? $this->options['animation_speed'] ?? 5);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<div class="vh-flickr-mosaic" data-rows="%d" data-size="%d" data-speed="%s">
|
||||||
|
<div class="vh-mosaic-container"></div>
|
||||||
|
</div>',
|
||||||
|
$rows,
|
||||||
|
$size,
|
||||||
|
$speed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
VH_Flickr_Mosaic::get_instance();
|
||||||
Reference in New Issue
Block a user