This commit is contained in:
zuevav
2026-04-30 15:14:09 +03:00
parent 08fe53fa5c
commit e5a88665cd
25 changed files with 13697 additions and 0 deletions
+409
View File
@@ -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 | `![{title}]({url})` | 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)
+949
View File
@@ -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()]);
}
+258
View File
@@ -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';
}
}
+395
View File
@@ -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)),
];
}
}
+272
View File
@@ -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);
}
}
}
+153
View File
@@ -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);
}
}
+200
View File
@@ -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' => '![{title}]({url})',
'separator' => "\n",
],
'markdown_linked' => [
'name' => 'Markdown (clickable)',
'description' => 'Markdown with link to original',
'template' => '[![{title}]({url})]({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;
}
}
+283
View File
@@ -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;
}
}
+403
View File
@@ -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'];
}
}
+32
View File
@@ -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' => '![{title}]({url})',
'markdown_linked' => '[![{title}]({url})]({original})',
'url' => '{url}',
],
];
+32
View File
@@ -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' => '![{title}]({url})',
'markdown_linked' => '[![{title}]({url})]({original})',
'url' => '{url}',
],
];
+229
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Deny from all
+579
View File
@@ -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
View File
@@ -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";
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

+4210
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -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>
+110
View File
@@ -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
View File
@@ -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();