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