From 5354f97baa48935d34963a902ccea06d417c8f24 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Fri, 13 Feb 2026 09:00:24 +0100 Subject: [PATCH] Articles: finish admin refactor, uploads hardening, and attachment sorting (0.262) --- DATABASE_STRUCTURE.md | 6 +- PROJECT_STRUCTURE.md | 16 + REFACTORING_PLAN.md | 28 + TESTING.md | 21 + admin/ajax.php | 3 +- admin/ajax/articles.php | 46 - .../articles/article-edit-custom-script.php | 583 +++++++++++++ admin/templates/articles/article-edit.php | 810 +----------------- admin/templates/articles/subpages-list.php | 12 +- admin/templates/shop-product/product-edit.php | 2 + autoload/Domain/Article/ArticleRepository.php | 228 ++++- .../admin/Controllers/ArticlesController.php | 320 ++++++- .../admin/Support/Forms/FormFieldRenderer.php | 17 +- autoload/admin/ViewModels/Forms/FormField.php | 28 +- .../admin/ViewModels/Forms/FormFieldType.php | 1 + autoload/admin/class.Site.php | 5 + autoload/admin/view/class.Articles.php | 22 - autoload/class.Image.php | 49 +- libraries/grid/config.php | 21 +- libraries/plupload/upload-articles-files.php | 193 +---- libraries/plupload/upload-articles-images.php | 200 ++--- libraries/plupload/upload-common.php | 307 +++++++ libraries/plupload/upload-product-files.php | 193 +---- libraries/plupload/upload-product-images.php | 201 ++--- .../Domain/Article/ArticleRepositoryTest.php | 100 +++ .../Controllers/ArticlesControllerTest.php | 44 + tests/bootstrap.php | 1 + updates/0.20/ver_0.262.zip | Bin 0 -> 43164 bytes updates/0.20/ver_0.262_files.txt | 2 + updates/0.20/ver_0.262_sql.txt | 2 + updates/versions.php | 2 +- 31 files changed, 1951 insertions(+), 1512 deletions(-) delete mode 100644 admin/ajax/articles.php create mode 100644 admin/templates/articles/article-edit-custom-script.php delete mode 100644 autoload/admin/view/class.Articles.php create mode 100644 libraries/plupload/upload-common.php create mode 100644 updates/0.20/ver_0.262.zip create mode 100644 updates/0.20/ver_0.262_files.txt create mode 100644 updates/0.20/ver_0.262_sql.txt diff --git a/DATABASE_STRUCTURE.md b/DATABASE_STRUCTURE.md index cd858d0..44c989e 100644 --- a/DATABASE_STRUCTURE.md +++ b/DATABASE_STRUCTURE.md @@ -134,10 +134,14 @@ Pliki artykułów. | Kolumna | Opis | |---------|------| +| id | PK | | article_id | FK do pp_articles | | src | Ścieżka do pliku | +| name | Nazwa wyświetlana załącznika (opcjonalna) | +| to_delete | Flaga miękkiego usuwania (0/1) | +| o | Kolejność załączników (używana przez sortowanie drag&drop w adminie) | -**Używane w:** `Domain\Article\ArticleRepository::find()` +**Używane w:** `Domain\Article\ArticleRepository::find()`, `Domain\Article\ArticleRepository::saveFilesOrder()` ## pp_units Jednostki/slowniki (np. jednostki produktu). diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 738c505..f8817ca 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -455,3 +455,19 @@ Aktualnie w suite są też testy modułów `Dictionaries`, `Articles` i `Users` - UPDATE: `/admin/articles_archive/view_list/` przepiete z legacy `grid` na `components/table-list`. - CLEANUP: usuniete legacy klasy `autoload/admin/controls/class.ArticlesArchive.php`, `autoload/admin/factory/class.ArticlesArchive.php`, `autoload/admin/view/class.ArticlesArchive.php`. - Testy: 165 tests, 424 assertions. + +### 2026-02-13: Refaktoryzacja /admin/articles (ver. 0.261) +- **UPDATE:** routing DI dla `ArticlesController` obsluguje akcje AJAX: `article_image_alt_change`, `article_file_name_change`, `article_image_delete`, `article_file_delete`. +- **UPDATE:** widok `admin/templates/articles/article-edit.php` korzysta z endpointow `/admin/articles/*` zamiast `admin/ajax.php?a=article_*`. +- **UPDATE:** lista artykulow nie korzysta juz z `admin\factory\Articles::article_pages` (etykiety stron z `Domain\Article\ArticleRepository`). +- **CLEANUP:** usuniete legacy pliki `autoload/admin/view/class.Articles.php` i `admin/ajax/articles.php`; odpiecie include w `admin/ajax.php`. +- Testy: 176 tests, 439 assertions. + +### 2026-02-13: Articles edit UX i sortowanie zalacznikow (ver. 0.262) +- **UPDATE:** `Domain\Article\ArticleRepository` - dodane `saveFilesOrder()` oraz obsluga `files_order` podczas `save()` (pierwszy zapis zachowuje kolejnosc). +- **UPDATE:** routing DI (`admin\Site::$actionMap`) rozszerzony o `files_order_save -> filesOrderSave`. +- **UPDATE:** `admin\Controllers\ArticlesController` - nowa akcja AJAX `filesOrderSave()`. +- **UPDATE:** `admin/templates/articles/article-edit-custom-script.php` - drag&drop sortowania listy zalacznikow + synchronizacja hidden input `files_order`. +- **UPDATE:** potwierdzenia usuwania zdjec i zalacznikow w edycji artykulu ujednolicone wizualnie z dialogiem usuwania z listy (jquery-confirm, `table-list-confirm-dialog`). +- **FIX:** dodane ladowanie biblioteki `jquery-impromptu` w widoku edycji artykulu (kompatybilnosc dla `$.prompt`). +- Testy: 178 tests, 443 assertions. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 8e48b33..a62c7f8 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -654,3 +654,31 @@ Gdy `persist = true`: - UPDATE: routing DI (`admin\Site`) rozszerzony o modul `ArticlesArchive` + mapowanie `article_restore -> restore` - CLEANUP: usuniete `autoload/admin/controls/class.ArticlesArchive.php`, `autoload/admin/factory/class.ArticlesArchive.php`, `autoload/admin/view/class.ArticlesArchive.php` - Testy po zmianie: **165 tests, 424 assertions** + +## Plan 2026-02-13 - Refaktoryzacja `/admin/articles/` +- [ ] Przeniesc zaleznosci listy artykulow z `admin\factory\Articles` do `Domain\Article\ArticleRepository` (etykiety stron, operacje pomocnicze). +- [ ] Dodac akcje routowane przez `admin\Controllers\ArticlesController` dla operacji AJAX (`article_image_alt_change`, `article_file_name_change`, `article_image_delete`, `article_file_delete`). +- [ ] Przepiac widok `admin/templates/articles/article-edit.php` z `/admin/ajax.php` na endpointy `/admin/articles/*`. +- [ ] Usunac legacy `admin\view\Articles` i zastapic rekurencje podstron przez `Tpl::view('articles/subpages-list', ...)`. +- [ ] Usunac `admin/ajax/articles.php` oraz odpiac include z `admin/ajax.php`. +- [ ] Przeszukac projekt pod pozostale zaleznosci i uruchomic testy modulu Articles. + +## Aktualizacja 2026-02-13 (ver. 0.261) +- **Articles** - dalsza refaktoryzacja `/admin/articles/` + - UPDATE: `Domain\Article\ArticleRepository` rozszerzone o metody UI/admin: `pagesSummaryForArticles()`, `updateImageAlt()`, `updateFileName()`, `markImageToDelete()`, `markFileToDelete()`. + - UPDATE: `admin\Controllers\ArticlesController` obsluguje nowe akcje routingu: `article_image_alt_change`, `article_file_name_change`, `article_image_delete`, `article_file_delete`. + - UPDATE: lista artykulow (`list`) nie korzysta juz z `admin\factory\Articles::article_pages()`. + - UPDATE: `admin/templates/articles/article-edit.php` przepiete z `/admin/ajax.php?a=article_*` na endpointy `/admin/articles/article_*/`. + - UPDATE: rekurencja podstron w widoku oparta o `Tpl::view('articles/subpages-list', ...)` (bez `admin\view\Articles`). + - CLEANUP: usuniete legacy pliki `autoload/admin/view/class.Articles.php` oraz `admin/ajax/articles.php`; `admin/ajax.php` nie includuje juz `ajax/articles.php`. +- Testy po zmianie: **176 tests, 439 assertions**. + +## Aktualizacja 2026-02-13 (ver. 0.262) +- **Articles (/admin/articles)** + - UPDATE: `Domain\Article\ArticleRepository` rozszerzone o `saveFilesOrder()` oraz zapis `files_order` przy `save()` (eliminuje koniecznosc drugiego zapisu po sortowaniu). + - UPDATE: routing DI (`admin\Site`) rozszerzony o mapowanie `files_order_save -> filesOrderSave`. + - UPDATE: `admin\Controllers\ArticlesController` - nowa akcja AJAX `filesOrderSave`. + - UPDATE: widok `admin/templates/articles/article-edit-custom-script.php` - drag&drop dla listy zalacznikow + hidden input `files_order`. + - UPDATE: potwierdzenia usuwania zdjec i zalacznikow przepiete na `jquery-confirm` ze stylem `table-list-confirm-dialog` (jak na liscie artykulow). + - FIX: dolaczona biblioteka `jquery-impromptu` w widoku edycji artykulu dla kompatybilnosci. +- Testy po zmianie: **178 tests, 443 assertions**. diff --git a/TESTING.md b/TESTING.md index a5c83dd..ad519b2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -246,3 +246,24 @@ OK (165 tests, 424 assertions) Nowe testy dodane 2026-02-12: - `tests/Unit/Domain/Article/ArticleRepositoryTest.php` (rozszerzenie o testy `restore`, `deletePermanently`, `listArchivedForAdmin`) - `tests/Unit/admin/Controllers/ArticlesArchiveControllerTest.php` + +## Aktualizacja suite (release 0.261) +Ostatnio zweryfikowano: 2026-02-13 + +```text +OK (176 tests, 439 assertions) +``` + +Nowe testy/rozszerzenia 2026-02-13: +- `tests/Unit/Domain/Article/ArticleRepositoryTest.php` (nowe przypadki dla `pagesSummaryForArticles`, `updateImageAlt`, `markFileToDelete`) +- `tests/Unit/admin/Controllers/ArticlesControllerTest.php` (nowe kontrakty dla akcji `imageAltChange`, `fileNameChange`, `imageDelete`, `fileDelete`) + +## Aktualizacja suite (release 0.262) +Ostatnio zweryfikowano: 2026-02-13 + +```text +OK (178 tests, 443 assertions) +``` + +Nowe testy/rozszerzenia 2026-02-13: +- `tests/Unit/Domain/Article/ArticleRepositoryTest.php` (nowe przypadki dla `saveFilesOrder`) diff --git a/admin/ajax.php b/admin/ajax.php index e951dcd..eeef67c 100644 --- a/admin/ajax.php +++ b/admin/ajax.php @@ -38,8 +38,7 @@ $mdb = new medoo( [ ] ); require_once 'ajax/pages.php'; -require_once 'ajax/articles.php'; require_once 'ajax/shop-category.php'; require_once 'ajax/users.php'; require_once 'ajax/shop.php'; -?> \ No newline at end of file +?> diff --git a/admin/ajax/articles.php b/admin/ajax/articles.php deleted file mode 100644 index 66d52d6..0000000 --- a/admin/ajax/articles.php +++ /dev/null @@ -1,46 +0,0 @@ - 'error', 'msg' => 'Podczas usuwania zdjecia wystąpił błąd. Proszę spróbować ponownie.' ]; - - if ( \admin\factory\Articles::delete_img( \S::get( 'image_id' ) ) ) - $response = [ 'status' => 'ok' ]; - - echo json_encode( $response ); - exit; -} - -if ( $a == 'article_file_delete' ) -{ - $response = [ 'status' => 'error', 'msg' => 'Podczas usuwania załącznika wystąpił błąd. Proszę spróbować ponownie.' ]; - - if ( \admin\factory\Articles::delete_file( \S::get( 'file_id' ) ) ) - $response = [ 'status' => 'ok' ]; - - echo json_encode( $response ); - exit; -} - -if ( $a == 'article_image_alt_change' ) -{ - $response = [ 'status' => 'error', 'msg' => 'Podczas zmiany atrybutu alt zdjęcia wystąpił błąd. Proszę spróbować ponownie.' ]; - - if ( \admin\factory\Articles::image_alt_change( \S::get( 'image_id' ), \S::get( 'image_alt' ) ) ) - $response = [ 'status' => 'ok' ]; - - echo json_encode( $response ); - exit; -} - -if ( $a == 'article_file_name_change' ) -{ - $response = [ 'status' => 'error', 'msg' => 'Podczas zmiany nazwy załącznika wystąpił błąd. Proszę spróbować ponownie.' ]; - - if ( \admin\factory\Articles::file_name_change( \S::get( 'file_id' ), \S::get( 'file_name' ) ) ) - $response = [ 'status' => 'ok' ]; - - echo json_encode( $response ); - exit; -} \ No newline at end of file diff --git a/admin/templates/articles/article-edit-custom-script.php b/admin/templates/articles/article-edit-custom-script.php new file mode 100644 index 0000000..51a0b71 --- /dev/null +++ b/admin/templates/articles/article-edit-custom-script.php @@ -0,0 +1,583 @@ +article ?? null) ? $this->article : []; +$articleId = (int)($article['id'] ?? 0); +$userId = (int)($this->user['id'] ?? 0); +$imagesCount = is_array($article['images'] ?? null) ? count($article['images']) : 0; +$filesCount = is_array($article['files'] ?? null) ? count($article['files']) : 0; + +$imageMaxPx = 1920; +if (isset($this->settings['image_px']) && (int)$this->settings['image_px'] > 0) { + $imageMaxPx = (int)$this->settings['image_px']; +} elseif (isset($GLOBALS['settings']['image_px']) && (int)$GLOBALS['settings']['image_px'] > 0) { + $imageMaxPx = (int)$GLOBALS['settings']['image_px']; +} + +$uploadToken = bin2hex(random_bytes(24)); +if (!isset($_SESSION['upload_tokens']) || !is_array($_SESSION['upload_tokens'])) { + $_SESSION['upload_tokens'] = []; +} +$_SESSION['upload_tokens'][$uploadToken] = [ + 'user_id' => $userId, + 'expires' => time() + 60 * 20, +]; + +$cookiePages = []; +$cookieMenus = []; +if (!empty($_COOKIE['cookie_pages'])) { + $decoded = @unserialize($_COOKIE['cookie_pages']); + if (is_array($decoded)) { + $cookiePages = $decoded; + } +} +if (!empty($_COOKIE['cookie_menus'])) { + $decoded = @unserialize($_COOKIE['cookie_menus']); + if (is_array($decoded)) { + $cookieMenus = $decoded; + } +} +?> + + + + + + + + + + + + + + + + + + diff --git a/admin/templates/articles/article-edit.php b/admin/templates/articles/article-edit.php index 8cd9888..7d3e596 100644 --- a/admin/templates/articles/article-edit.php +++ b/admin/templates/articles/article-edit.php @@ -1,804 +1,6 @@ - - - - $this -> user['id'], - 'expires' => time() + 60*20 -]; - -$_SESSION['rfm_akey'] = bin2hex(random_bytes(16)); -$_SESSION['rfm_akey_expires'] = time() + 20*60; -$_SESSION['can_use_rfm'] = true; -$rfmAkeyJS = $_SESSION['rfm_akey']; - -ob_start(); -?> -
- -
-
-
-
    - languages ) ): foreach ( $this -> languages as $lg ):?> - -
  • ';?>
  • - - -
-
- languages ) ): foreach ( $this -> languages as $lg ):?> - languages ) ) foreach ( $this -> languages as $lg_tmp ) - { - if ( $lg_tmp['id'] != $lg['id'] ) - $languages[ $lg_tmp['id'] ] = $lg_tmp['name']; - } - ?> - -
- 'Wyświetlaj treść z wersji', - 'name' => 'copy_from[' . $lg['id'] . ']', - 'values' => $languages, - 'value' => $this -> article['languages'][ $lg['id'] ]['copy_from'], - ) - );?> - 'Tytuł', - 'name' => 'title[' . $lg['id'] . ']', - 'id' => 'title_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['title'], - 'inline' => true - ) - );?> - 'Zdjęcie tytułowe', - 'name' => 'main_image[' . $lg['id'] . ']', - 'id' => 'main_image_' . $lg['id'], - 'value' => htmlspecialchars( $this -> article['languages'][ $lg['id'] ]['main_image'] ), - 'icon_content' => 'przeglądaj', - 'inline' => true, - 'icon_js' => "window.open ( '/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=main_image_" . $lg['id'] . "&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');" - ] ); - ?> - 'Wstęp', - 'name' => 'entry[' . $lg['id'] . ']', - 'id' => 'entry_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['entry'], - 'inline' => true - ) - );?> - 'Treść', - 'name' => 'text[' . $lg['id'] . ']', - 'id' => 'text_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['text'], - 'inline' => true - ) - );?> - 'Spis treści', - 'name' => 'table_of_contents[' . $lg['id'] . ']', - 'id' => 'table_of_contents_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['table_of_contents'], - 'inline' => true - ] );?> - -
- - -
-
-
-
-
- 'Opublikowany', - 'name' => 'status', - 'checked' => $this -> article['status'] == 1 or !$this -> article['id'] ? true : false - ) - );?> - 'Pokaż tytuł', - 'name' => 'show_title', - 'checked' => $this -> article['show_title'] == 1 ? true : false - ) - );?> - 'Pokaż spis treści', - 'name' => 'show_table_of_contents', - 'checked' => $this -> article['show_table_of_contents'] == 1 ? true : false - ] );?> - 'Pokaż datę dodania', - 'name' => 'show_date_add', - 'checked' => $this -> article['show_date_add'] == 1 ? true : false - ) - );?> - 'Pokaż datę modyfikacji', - 'name' => 'show_date_modify', - 'checked' => $this -> article['show_date_modify'] == 1 ? true : false - ) - );?> - 'Powtórz wprowadzenie', - 'name' => 'repeat_entry', - 'checked' => $this -> article['repeat_entry'] == 1 ? true : false - ) - );?> - 'Linki do portali społecznościowych', - 'name' => 'social_icons', - 'checked' => $this -> article['social_icons'] == 1 ? true : false - ) - );?> -
-
-
-
    - languages ) ): foreach ( $this -> languages as $lg ):?> - -
  • ';?>
  • - - -
-
- languages ) ): foreach ( $this -> languages as $lg ):?> - -
- 'Link SEO', - 'name' => 'seo_link[' . $lg['id'] . ']', - 'id' => 'seo_link_' . $lg['id'], - 'value' => $this -> article['languages' ][ $lg['id'] ]['seo_link'], - 'icon_content' => 'generuj', - 'icon_js' => 'generate_seo_links( "' . $lg['id'] . '", $( "#title_' . $lg['id'] . '" ).val(), ' . (int)$this -> article['id'] . ' );' - ) - );?> - 'Meta title', - 'name' => 'meta_title[' . $lg['id'] . ']', - 'id' => 'meta_title_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['meta_title'] - ] - );?> - 'Meta description', - 'name' => 'meta_description[' . $lg['id'] . ']', - 'id' => 'meta_description_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['meta_description'] - ) - );?> - 'Meta keywords', - 'name' => 'meta_keywords[' . $lg['id'] . ']', - 'id' => 'meta_keywords_' . $lg['id'], - 'value' => $this -> article['languages'][ $lg['id'] ]['meta_keywords'] - ) - );?> - 'Blokuj indeksację', - 'name' => 'noindex[' . $lg['id'] . ']', - 'id' => 'noindex_' . $lg['id'], - 'checked' => $this -> article['languages'][ $lg['id'] ]['noindex'] == 1 ? 1 : 0 - ] - );?> - 'Blokuj bezpośredni dostęp', - 'name' => 'block_direct_access[' . $lg['id'] . ']', - 'id' => 'block_direct_access_' . $lg['id'], - 'checked' => $this -> article['languages'][ $lg['id'] ]['block_direct_access'] == 1 ? 1 : 0 - ] );?> -
- - -
-
-
-
-
- layouts ) ): foreach ( $this -> layouts as $layout ): - $layouts[ $layout['id'] ] = $layout['name']; - endforeach; endif; - ?> - 'Szablon', - 'name' => 'layout_id', - 'id' => 'layout_id', - 'values' => $layouts, - 'value' => $this -> article['layout_id'] - ) - );?> -
- -
- menus ) ) foreach ( $this -> menus as $menu ) - { - ?> - - -
-
-
-
-
-
    - article['images'] ) ): foreach ( $this -> article['images'] as $img ): - ?> -
  • - - - - - -
  • - -
-
You browser doesn't have Flash installed.
-
-
-
    - article['files'] ) ): foreach ( $this -> article['files'] as $file ): - - if ( $file['name'] ) - $name = $file['name']; - else - { - $name = explode( '/', $file['src'] ); - $name = $name[ count( $name ) - 1 ]; - } - ?> -
  • -
    - - - - -
    -
  • - -
-
You browser doesn't have Flash installed.
-
-
-
- id = 'article-edit'; -$grid -> gdb_opt = $gdb; -$grid -> include_plugins = true; -$grid -> title = 'Edycja artykułu'; -$grid -> fields = [ - [ - 'db' => 'id', - 'type' => 'hidden', - 'value' => $this -> article['id'] - ] - ]; -$grid -> actions = [ - 'save' => [ 'url' => '/admin/articles/article_save/', 'back_url' => '/admin/articles/view_list/' ], - 'cancel' => [ 'url' => '/admin/articles/view_list/' ] - ]; -$grid -> external_code = $out; -$grid -> persist_edit = true; -$grid -> id_param = 'id'; - -echo $grid -> draw(); -?> - - - - - - - - - - - - - - + $this->form]); ?> + $this->article, + 'user' => $this->user, + 'languages' => $this->form->languages ?? [] +]); ?> diff --git a/admin/templates/articles/subpages-list.php b/admin/templates/articles/subpages-list.php index 2ab0071..6cb10e6 100644 --- a/admin/templates/articles/subpages-list.php +++ b/admin/templates/articles/subpages-list.php @@ -6,11 +6,13 @@ article_pages ) and in_array( $page['id'], $this -> article_pages ) ):?>checked="checked" /> - article_pages, $page['id'], $this -> step + 1 ); - ?> + $page['subpages'], + 'article_pages' => $this->article_pages, + 'parent_id' => $page['id'], + 'step' => $this->step + 1, + ] ); ?> - \ No newline at end of file + diff --git a/admin/templates/shop-product/product-edit.php b/admin/templates/shop-product/product-edit.php index 9289ad8..e4a216a 100644 --- a/admin/templates/shop-product/product-edit.php +++ b/admin/templates/shop-product/product-edit.php @@ -949,6 +949,7 @@ echo $grid->draw(); }, url: '/libraries/plupload/upload-product-images.php', chunk_size: '1mb', + max_file_size: '20mb', unique_names: false, resize: { width: settings['image_px'] ? $this->settings['image_px'] : '1920'; ?>, @@ -991,6 +992,7 @@ echo $grid->draw(); }, url: '/libraries/plupload/upload-product-files.php', chunk_size: '1mb', + max_file_size: '50mb', unique_names: false, filters: [{ title: "Wszystkie pliki", diff --git a/autoload/Domain/Article/ArticleRepository.php b/autoload/Domain/Article/ArticleRepository.php index 429578c..41f7360 100644 --- a/autoload/Domain/Article/ArticleRepository.php +++ b/autoload/Domain/Article/ArticleRepository.php @@ -37,7 +37,15 @@ class ArticleRepository 'article_id' => $articleId, 'ORDER' => ['o' => 'ASC', 'id' => 'DESC'] ]); - $article['files'] = $this->db->select('pp_articles_files', '*', ['article_id' => $articleId]); + try { + $article['files'] = $this->db->select('pp_articles_files', '*', [ + 'article_id' => $articleId, + 'ORDER' => ['o' => 'ASC', 'id' => 'DESC'] + ]); + } catch (\Throwable $e) { + // Fallback for instances where pp_articles_files does not yet have "o" column. + $article['files'] = $this->db->select('pp_articles_files', '*', ['article_id' => $articleId]); + } $article['pages'] = $this->db->select('pp_articles_pages', 'page_id', ['article_id' => $articleId]); return $article; @@ -70,6 +78,8 @@ class ArticleRepository $this->savePages($id, $data['pages'] ?? null, true); $this->assignTempFiles($id); $this->assignTempImages($id); + $this->applyGalleryOrderIfProvided($id, $data); + $this->applyFilesOrderIfProvided($id, $data); \S::htacces(); \S::delete_dir('../temp/'); @@ -87,6 +97,8 @@ class ArticleRepository $this->savePages($articleId, $data['pages'] ?? null, false); $this->assignTempFiles($articleId); $this->assignTempImages($articleId); + $this->applyGalleryOrderIfProvided($articleId, $data); + $this->applyFilesOrderIfProvided($articleId, $data); $this->deleteMarkedImages($articleId); $this->deleteMarkedFiles($articleId); @@ -99,16 +111,16 @@ class ArticleRepository private function buildArticleRow(array $data, int $userId, bool $isNew): array { $row = [ - 'show_title' => ($data['show_title'] ?? '') == 'on' ? 1 : 0, - 'show_date_add' => ($data['show_date_add'] ?? '') == 'on' ? 1 : 0, - 'show_date_modify' => ($data['show_date_modify'] ?? '') == 'on' ? 1 : 0, + 'show_title' => $this->isCheckedValue($data['show_title'] ?? null) ? 1 : 0, + 'show_date_add' => $this->isCheckedValue($data['show_date_add'] ?? null) ? 1 : 0, + 'show_date_modify' => $this->isCheckedValue($data['show_date_modify'] ?? null) ? 1 : 0, 'date_modify' => date('Y-m-d H:i:s'), 'modify_by' => $userId, 'layout_id' => !empty($data['layout_id']) ? (int)$data['layout_id'] : null, - 'status' => ($data['status'] ?? '') == 'on' ? 1 : 0, - 'repeat_entry' => ($data['repeat_entry'] ?? '') == 'on' ? 1 : 0, - 'social_icons' => ($data['social_icons'] ?? '') == 'on' ? 1 : 0, - 'show_table_of_contents' => ($data['show_table_of_contents'] ?? '') == 'on' ? 1 : 0, + 'status' => $this->isCheckedValue($data['status'] ?? null) ? 1 : 0, + 'repeat_entry' => $this->isCheckedValue($data['repeat_entry'] ?? null) ? 1 : 0, + 'social_icons' => $this->isCheckedValue($data['social_icons'] ?? null) ? 1 : 0, + 'show_table_of_contents' => $this->isCheckedValue($data['show_table_of_contents'] ?? null) ? 1 : 0, ]; if ($isNew) { @@ -131,12 +143,32 @@ class ArticleRepository 'meta_description' => ($data['meta_description'][$langId] ?? '') != '' ? $data['meta_description'][$langId] : null, 'meta_keywords' => ($data['meta_keywords'][$langId] ?? '') != '' ? $data['meta_keywords'][$langId] : null, 'seo_link' => \S::seo($data['seo_link'][$langId] ?? '') != '' ? \S::seo($data['seo_link'][$langId]) : null, - 'noindex' => ($data['noindex'][$langId] ?? '') == 'on' ? 1 : 0, + 'noindex' => $this->isCheckedValue($data['noindex'][$langId] ?? null) ? 1 : 0, 'copy_from' => ($data['copy_from'][$langId] ?? '') != '' ? $data['copy_from'][$langId] : null, - 'block_direct_access' => ($data['block_direct_access'][$langId] ?? '') == 'on' ? 1 : 0, + 'block_direct_access' => $this->isCheckedValue($data['block_direct_access'][$langId] ?? null) ? 1 : 0, ]; } + private function applyGalleryOrderIfProvided(int $articleId, array $data): void + { + $order = trim((string)($data['gallery_order'] ?? '')); + if ($order === '') { + return; + } + + $this->saveGalleryOrder($articleId, $order); + } + + private function applyFilesOrderIfProvided(int $articleId, array $data): void + { + $order = trim((string)($data['files_order'] ?? '')); + if ($order === '') { + return; + } + + $this->saveFilesOrder($articleId, $order); + } + private function saveTranslations(int $articleId, array $data, bool $isNew): void { $titles = $data['title'] ?? []; @@ -573,6 +605,182 @@ class ArticleRepository return true; } + /** + * Zapisuje kolejnosc zalacznikow artykulu. + */ + public function saveFilesOrder(int $articleId, string $order): bool + { + $fileIds = explode(';', $order); + if (!is_array($fileIds) || empty($fileIds)) { + return true; + } + + $position = 0; + foreach ($fileIds as $fileId) { + if ($fileId === '' || $fileId === null) { + continue; + } + + try { + $this->db->update('pp_articles_files', [ + 'o' => $position++, + ], [ + 'AND' => [ + 'article_id' => $articleId, + 'id' => (int)$fileId, + ], + ]); + } catch (\Throwable $e) { + // Fallback for instances where pp_articles_files does not yet have "o" column. + return true; + } + } + + return true; + } + + /** + * Zwraca mape: article_id => etykieta stron (np. " - Strona A / Strona B"). + * + * @param array $articleIds + * @return array + */ + public function pagesSummaryForArticles(array $articleIds): array + { + $normalizedIds = []; + foreach ($articleIds as $articleId) { + $id = (int)$articleId; + if ($id > 0) { + $normalizedIds[$id] = $id; + } + } + + if (empty($normalizedIds)) { + return []; + } + + $placeholders = []; + $params = []; + foreach (array_values($normalizedIds) as $index => $id) { + $key = ':article_id_' . $index; + $placeholders[] = $key; + $params[$key] = $id; + } + + $sql = " + SELECT + ap.article_id, + ap.page_id, + ( + SELECT title + FROM pp_pages_langs AS ppl, pp_langs AS pl + WHERE ppl.lang_id = pl.id AND ppl.page_id = ap.page_id AND ppl.title != '' + ORDER BY pl.o ASC + LIMIT 1 + ) AS title + FROM pp_articles_pages AS ap + WHERE ap.article_id IN (" . implode(', ', $placeholders) . ") + ORDER BY ap.article_id ASC, ap.o ASC, ap.page_id ASC + "; + + $stmt = $this->db->query($sql, $params); + $rows = $stmt ? $stmt->fetchAll() : []; + if (!is_array($rows)) { + return []; + } + + $titlesByArticle = []; + foreach ($rows as $row) { + $articleId = (int)($row['article_id'] ?? 0); + if ($articleId <= 0) { + continue; + } + + $title = trim((string)($row['title'] ?? '')); + if ($title === '') { + continue; + } + + $titlesByArticle[$articleId][] = $title; + } + + $summary = []; + foreach (array_values($normalizedIds) as $articleId) { + if (empty($titlesByArticle[$articleId])) { + $summary[$articleId] = ''; + continue; + } + + $summary[$articleId] = ' - ' . implode(' / ', $titlesByArticle[$articleId]); + } + + return $summary; + } + + public function updateImageAlt(int $imageId, string $imageAlt): bool + { + $result = $this->db->update('pp_articles_images', [ + 'alt' => $imageAlt, + ], [ + 'id' => $imageId, + ]); + + \S::delete_cache(); + + return (bool)$result; + } + + public function updateFileName(int $fileId, string $fileName): bool + { + $result = $this->db->update('pp_articles_files', [ + 'name' => $fileName, + ], [ + 'id' => $fileId, + ]); + + return (bool)$result; + } + + public function markFileToDelete(int $fileId): bool + { + $result = $this->db->update('pp_articles_files', [ + 'to_delete' => 1, + ], [ + 'id' => $fileId, + ]); + + return (bool)$result; + } + + public function markImageToDelete(int $imageId): bool + { + $result = $this->db->update('pp_articles_images', [ + 'to_delete' => 1, + ], [ + 'id' => $imageId, + ]); + + return (bool)$result; + } + + private function isCheckedValue($value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return ((int)$value) === 1; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + return in_array($normalized, ['1', 'on', 'true', 'yes'], true); + } + + return false; + } + private function appendDateRangeFilter( array &$where, array &$params, diff --git a/autoload/admin/Controllers/ArticlesController.php b/autoload/admin/Controllers/ArticlesController.php index 9766a01..d8ecf6e 100644 --- a/autoload/admin/Controllers/ArticlesController.php +++ b/autoload/admin/Controllers/ArticlesController.php @@ -4,6 +4,10 @@ namespace admin\Controllers; use Domain\Article\ArticleRepository; use Domain\Languages\LanguagesRepository; use Domain\Layouts\LayoutsRepository; +use admin\ViewModels\Forms\FormAction; +use admin\ViewModels\Forms\FormEditViewModel; +use admin\ViewModels\Forms\FormField; +use admin\ViewModels\Forms\FormTab; class ArticlesController { @@ -61,12 +65,21 @@ class ArticlesController $listRequest['perPage'] ); + $articleIds = []; + foreach ($result['items'] as $item) { + $id = (int)($item['id'] ?? 0); + if ($id > 0) { + $articleIds[] = $id; + } + } + $pagesSummary = $this->repository->pagesSummaryForArticles($articleIds); + $rows = []; $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1; foreach ($result['items'] as $item) { $id = (int)$item['id']; $title = (string)($item['title'] ?? ''); - $pages = (string)\admin\factory\Articles::article_pages($id); + $pages = (string)($pagesSummary[$id] ?? ''); $rows[] = [ 'lp' => $lp++ . '.', @@ -146,6 +159,18 @@ class ArticlesController exit; } + /** + * Zapis kolejnosci zalacznikow (AJAX) + */ + public function filesOrderSave(): void + { + if ($this->repository->saveFilesOrder((int)\S::get('article_id'), (string)\S::get('order'))) { + echo json_encode(['status' => 'ok', 'msg' => 'Artykul zostal zapisany.']); + } + + exit; + } + /** * Zapis artykulu (AJAX) */ @@ -153,11 +178,72 @@ class ArticlesController { global $user; - $values = json_decode(\S::get('values'), true); - $response = ['status' => 'error', 'msg' => 'Podczas zapisywania artykulu wystapil blad. Prosze sprobowac ponownie.']; + $values = $this->resolveSavePayload(); + $articleId = (int)($values['id'] ?? \S::get('id') ?? 0); + $id = $this->repository->save($articleId, $values, (int)$user['id']); - if ($id = $this->repository->save((int)($values['id'] ?? 0), $values, (int)$user['id'])) { - $response = ['status' => 'ok', 'msg' => 'Artykul zostal zapisany.', 'id' => $id]; + if ($id) { + echo json_encode([ + 'success' => true, + 'status' => 'ok', + 'message' => 'Artykul zostal zapisany.', + 'msg' => 'Artykul zostal zapisany.', + 'id' => (int)$id, + ]); + exit; + } + + echo json_encode([ + 'success' => false, + 'status' => 'error', + 'message' => 'Podczas zapisywania artykulu wystapil blad. Prosze sprobowac ponownie.', + 'msg' => 'Podczas zapisywania artykulu wystapil blad. Prosze sprobowac ponownie.', + ]); + exit; + } + + public function imageAltChange(): void + { + $response = ['status' => 'error', 'msg' => 'Podczas zmiany atrybutu alt zdjecia wystapil blad. Prosze sprobowac ponownie.']; + + if ($this->repository->updateImageAlt((int)\S::get('image_id'), (string)\S::get('image_alt'))) { + $response = ['status' => 'ok']; + } + + echo json_encode($response); + exit; + } + + public function fileNameChange(): void + { + $response = ['status' => 'error', 'msg' => 'Podczas zmiany nazwy zalacznika wystapil blad. Prosze sprobowac ponownie.']; + + if ($this->repository->updateFileName((int)\S::get('file_id'), (string)\S::get('file_name'))) { + $response = ['status' => 'ok']; + } + + echo json_encode($response); + exit; + } + + public function imageDelete(): void + { + $response = ['status' => 'error', 'msg' => 'Podczas usuwania zdjecia wystapil blad. Prosze sprobowac ponownie.']; + + if ($this->repository->markImageToDelete((int)\S::get('image_id'))) { + $response = ['status' => 'ok']; + } + + echo json_encode($response); + exit; + } + + public function fileDelete(): void + { + $response = ['status' => 'error', 'msg' => 'Podczas usuwania zalacznika wystapil blad. Prosze sprobowac ponownie.']; + + if ($this->repository->markFileToDelete((int)\S::get('file_id'))) { + $response = ['status' => 'ok']; } echo json_encode($response); @@ -192,12 +278,226 @@ class ArticlesController $this->repository->deleteNonassignedImages(); $this->repository->deleteNonassignedFiles(); + $article = $this->repository->find((int)\S::get('id')) ?: ['id' => 0, 'languages' => [], 'images' => [], 'files' => [], 'pages' => []]; + $languages = $this->languagesRepository->languagesList(); + $menus = \admin\factory\Pages::menus_list(); + $layouts = $this->layoutsRepository->listAll(); + + $viewModel = $this->buildFormViewModel($article, $languages, $menus, $layouts); + return \Tpl::view('articles/article-edit', [ - 'article' => $this->repository->find((int)\S::get('id')), - 'menus' => \admin\factory\Pages::menus_list(), - 'languages' => $this->languagesRepository->languagesList(), - 'layouts' => $this->layoutsRepository->listAll(), - 'user' => $user + 'form' => $viewModel, + 'article' => $article, + 'user' => $user, ]); } + + private function resolveSavePayload(): array + { + $legacyValuesRaw = \S::get('values'); + if ($legacyValuesRaw !== null && $legacyValuesRaw !== '') { + $legacyValues = json_decode((string)$legacyValuesRaw, true); + if (is_array($legacyValues)) { + return $legacyValues; + } + } + + $payload = $_POST; + unset($payload['_form_id']); + + return is_array($payload) ? $payload : []; + } + + private function buildFormViewModel(array $article, array $languages, array $menus, array $layouts): FormEditViewModel + { + $articleId = (int)($article['id'] ?? 0); + $defaultLanguageId = (string)($this->languagesRepository->defaultLanguageId() ?? 'pl'); + $title = $articleId > 0 + ? 'Edycja artykulu: ' . $this->escapeHtml((string)($article['languages'][$defaultLanguageId]['title'] ?? '')) . '' + : 'Edycja artykulu'; + + $layoutOptions = ['' => '---- szablon domyslny ----']; + foreach ($layouts as $layout) { + $layoutOptions[(string)$layout['id']] = (string)$layout['name']; + } + + $copyFromOptions = ['' => '---- wersja jezykowa ----']; + foreach ($languages as $language) { + if (!empty($language['id'])) { + $copyFromOptions[(string)$language['id']] = (string)$language['name']; + } + } + + $tabs = [ + new FormTab('content', 'Tresc', 'fa-file'), + new FormTab('settings', 'Ustawienia', 'fa-wrench'), + new FormTab('seo', 'SEO', 'fa-globe'), + new FormTab('display', 'Wyswietlanie', 'fa-share-alt'), + new FormTab('gallery', 'Galeria', 'fa-file-image-o'), + new FormTab('files', 'Zalaczniki', 'fa-file-archive-o'), + ]; + + $fields = [ + FormField::hidden('id', $articleId), + FormField::langSection('article_content', 'content', [ + FormField::select('copy_from', [ + 'label' => 'Wyswietlaj tresc z wersji', + 'options' => $copyFromOptions, + ]), + FormField::text('title', [ + 'label' => 'Tytul', + 'required' => true, + 'attributes' => ['id' => 'title'], + ]), + FormField::image('main_image', [ + 'label' => 'Zdjecie tytulowe', + 'filemanager' => true, + 'attributes' => ['id' => 'main_image'], + ]), + FormField::editor('entry', [ + 'label' => 'Wstep', + 'toolbar' => 'MyToolbar', + 'height' => 250, + 'attributes' => ['id' => 'entry'], + ]), + FormField::editor('text', [ + 'label' => 'Tresc', + 'toolbar' => 'MyToolbar', + 'height' => 250, + 'attributes' => ['id' => 'text'], + ]), + FormField::editor('table_of_contents', [ + 'label' => 'Spis tresci', + 'toolbar' => 'MyToolbar', + 'height' => 250, + 'attributes' => ['id' => 'table_of_contents'], + ]), + ]), + FormField::switch('status', ['label' => 'Opublikowany', 'tab' => 'settings', 'value' => ((int)($article['status'] ?? 0) === 1) || $articleId === 0]), + FormField::switch('show_title', ['label' => 'Pokaz tytul', 'tab' => 'settings', 'value' => (int)($article['show_title'] ?? 0) === 1]), + FormField::switch('show_table_of_contents', ['label' => 'Pokaz spis tresci', 'tab' => 'settings', 'value' => (int)($article['show_table_of_contents'] ?? 0) === 1]), + FormField::switch('show_date_add', ['label' => 'Pokaz date dodania', 'tab' => 'settings', 'value' => (int)($article['show_date_add'] ?? 0) === 1]), + FormField::switch('show_date_modify', ['label' => 'Pokaz date modyfikacji', 'tab' => 'settings', 'value' => (int)($article['show_date_modify'] ?? 0) === 1]), + FormField::switch('repeat_entry', ['label' => 'Powtorz wprowadzenie', 'tab' => 'settings', 'value' => (int)($article['repeat_entry'] ?? 0) === 1]), + FormField::switch('social_icons', ['label' => 'Linki do portali spolecznosciowych', 'tab' => 'settings', 'value' => (int)($article['social_icons'] ?? 0) === 1]), + FormField::langSection('article_seo', 'seo', [ + FormField::text('seo_link', ['label' => 'Link SEO', 'attributes' => ['id' => 'seo_link']]), + FormField::text('meta_title', ['label' => 'Meta title']), + FormField::textarea('meta_description', ['label' => 'Meta description', 'rows' => 4]), + FormField::textarea('meta_keywords', ['label' => 'Meta keywords', 'rows' => 4]), + FormField::switch('noindex', ['label' => 'Blokuj indeksacje']), + FormField::switch('block_direct_access', ['label' => 'Blokuj bezposredni dostep']), + ]), + FormField::select('layout_id', [ + 'label' => 'Szablon', + 'tab' => 'display', + 'options' => $layoutOptions, + 'value' => $article['layout_id'] ?? '', + ]), + FormField::custom('pages_tree', $this->renderPagesTree($menus, $article), ['tab' => 'display']), + FormField::custom('images_box', $this->renderImagesBox($article), ['tab' => 'gallery']), + FormField::custom('files_box', $this->renderFilesBox($article), ['tab' => 'files']), + ]; + + $actions = [ + FormAction::save('/admin/articles/article_save/' . ($articleId > 0 ? 'id=' . $articleId : ''), '/admin/articles/view_list/'), + FormAction::cancel('/admin/articles/view_list/'), + ]; + + return new FormEditViewModel( + 'article-edit', + $title, + $article, + $fields, + $tabs, + $actions, + 'POST', + '/admin/articles/article_save/' . ($articleId > 0 ? 'id=' . $articleId : ''), + '/admin/articles/view_list/', + true, + ['id' => $articleId], + $languages + ); + } + + private function renderPagesTree(array $menus, array $article): string + { + $html = '
'; + $html .= ''; + $html .= '
'; + + foreach ($menus as $menu) { + $menuId = (int)($menu['id'] ?? 0); + $menuName = $this->escapeHtml((string)($menu['name'] ?? '')); + $menuStatus = (int)($menu['status'] ?? 0); + $menuPages = \admin\factory\Pages::menu_pages($menuId); + + $html .= ''; + } + + $html .= '
'; + + return $html; + } + + private function renderImagesBox(array $article): string + { + $html = '
    '; + $images = is_array($article['images'] ?? null) ? $article['images'] : []; + foreach ($images as $img) { + $id = (int)($img['id'] ?? 0); + $src = $this->escapeHtml((string)($img['src'] ?? '')); + $alt = $this->escapeHtml((string)($img['alt'] ?? '')); + + $html .= '
  • '; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= '
  • '; + } + $html .= '
You browser doesn\'t have Flash installed.
'; + + return $html; + } + + private function renderFilesBox(array $article): string + { + $html = '
    '; + $files = is_array($article['files'] ?? null) ? $article['files'] : []; + foreach ($files as $file) { + $id = (int)($file['id'] ?? 0); + $src = (string)($file['src'] ?? ''); + $name = trim((string)($file['name'] ?? '')); + if ($name === '') { + $parts = explode('/', $src); + $name = (string)end($parts); + } + $name = $this->escapeHtml($name); + + $html .= '
  • '; + $html .= ''; + $html .= ''; + $html .= '
  • '; + } + $html .= '
You browser doesn\'t have Flash installed.
'; + + return $html; + } + + private function escapeHtml(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } } diff --git a/autoload/admin/Support/Forms/FormFieldRenderer.php b/autoload/admin/Support/Forms/FormFieldRenderer.php index 5a35c10..9d256bf 100644 --- a/autoload/admin/Support/Forms/FormFieldRenderer.php +++ b/autoload/admin/Support/Forms/FormFieldRenderer.php @@ -201,7 +201,7 @@ class FormFieldRenderer 'name' => $field->name, 'id' => $field->id, 'value' => $value ?? '', - 'options' => $field->options, + 'values' => $field->options, 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), ]; @@ -308,6 +308,11 @@ class FormFieldRenderer 'value="' . htmlspecialchars($value ?? '') . '">'; } + public function renderCustom(FormField $field): string + { + return (string)($field->customHtml ?? ''); + } + /** * Renderuje sekcję językową */ @@ -388,6 +393,16 @@ class FormFieldRenderer 'id' => $id, 'checked' => (bool) $value, ]); + + case FormFieldType::SELECT: + return $this->wrapWithError(\Html::select([ + 'label' => $field->label, + 'name' => $name, + 'id' => $id, + 'value' => $value ?? '', + 'values' => $field->options, + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]), $error); default: // TEXT, URL, etc. return $this->wrapWithError(\Html::input([ diff --git a/autoload/admin/ViewModels/Forms/FormField.php b/autoload/admin/ViewModels/Forms/FormField.php index faaf46d..cc07788 100644 --- a/autoload/admin/ViewModels/Forms/FormField.php +++ b/autoload/admin/ViewModels/Forms/FormField.php @@ -29,6 +29,7 @@ class FormField // Specyficzne dla lang_section public ?array $langFields; public ?string $langSectionParentTab; + public ?string $customHtml; /** * @param string $name Nazwa pola (name) @@ -64,7 +65,8 @@ class FormField string $editorToolbar = 'MyTool', int $editorHeight = 300, ?array $langFields = null, - ?string $langSectionParentTab = null + ?string $langSectionParentTab = null, + ?string $customHtml = null ) { $this->name = $name; $this->type = $type; @@ -82,6 +84,7 @@ class FormField $this->editorHeight = $editorHeight; $this->langFields = $langFields; $this->langSectionParentTab = $langSectionParentTab; + $this->customHtml = $customHtml; $this->id = $attributes['id'] ?? $name; } @@ -276,6 +279,29 @@ class FormField ); } + public static function custom(string $name, string $html, array $config = []): self + { + return new self( + $name, + FormFieldType::CUSTOM, + $config['label'] ?? '', + null, + $config['tab'] ?? 'default', + false, + $config['attributes'] ?? [], + [], + null, + null, + false, + null, + 'MyTool', + 300, + null, + null, + $html + ); + } + /** * Sekcja językowa - grupa pól powtarzana dla każdego języka * diff --git a/autoload/admin/ViewModels/Forms/FormFieldType.php b/autoload/admin/ViewModels/Forms/FormFieldType.php index 66e68dc..579c15d 100644 --- a/autoload/admin/ViewModels/Forms/FormFieldType.php +++ b/autoload/admin/ViewModels/Forms/FormFieldType.php @@ -20,4 +20,5 @@ class FormFieldType public const FILE = 'file'; public const HIDDEN = 'hidden'; public const LANG_SECTION = 'lang_section'; + public const CUSTOM = 'custom'; } diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php index f519909..8777cd9 100644 --- a/autoload/admin/class.Site.php +++ b/autoload/admin/class.Site.php @@ -330,11 +330,16 @@ class Site */ private static $actionMap = [ 'gallery_order_save' => 'galleryOrderSave', + 'files_order_save' => 'filesOrderSave', 'view_list' => 'list', 'article_edit' => 'edit', 'article_save' => 'save', 'article_delete' => 'delete', 'article_restore' => 'restore', + 'article_image_alt_change' => 'imageAltChange', + 'article_file_name_change' => 'fileNameChange', + 'article_image_delete' => 'imageDelete', + 'article_file_delete' => 'fileDelete', 'banner_edit' => 'edit', 'banner_save' => 'save', 'banner_delete' => 'delete', diff --git a/autoload/admin/view/class.Articles.php b/autoload/admin/view/class.Articles.php deleted file mode 100644 index fb2879b..0000000 --- a/autoload/admin/view/class.Articles.php +++ /dev/null @@ -1,22 +0,0 @@ - pages = $pages; - $tpl -> parent_id = $parent_id; - $tpl -> step = $step; - $tpl -> article_pages = $article_pages; - return $tpl -> render( 'articles/subpages-list' ); - } - - public static function articles_list() - { - $tpl = new \Tpl; - return $tpl -> render( 'articles/articles-list' ); - } -} -?> diff --git a/autoload/class.Image.php b/autoload/class.Image.php index 0065afa..bd509ee 100644 --- a/autoload/class.Image.php +++ b/autoload/class.Image.php @@ -3,7 +3,8 @@ class ImageManipulator { protected int $width; protected int $height; - protected \GdImage $image; + /** @var resource|\GdImage */ + protected $image; protected ?string $file = null; /** @@ -37,7 +38,7 @@ class ImageManipulator throw new InvalidArgumentException("Image file $file is not readable"); } - if (isset($this->image) && $this->image instanceof \GdImage) { + if (isset($this->image) && $this->isValidImageResource($this->image)) { imagedestroy($this->image); } @@ -66,7 +67,7 @@ class ImageManipulator throw new InvalidArgumentException("Image type $type not supported"); } - if (!$this->image instanceof \GdImage) { + if (!$this->isValidImageResource($this->image)) { throw new InvalidArgumentException("Failed to create image from $file"); } @@ -91,12 +92,12 @@ class ImageManipulator */ public function setImageString(string $data): self { - if (isset($this->image) && $this->image instanceof \GdImage) { + if (isset($this->image) && $this->isValidImageResource($this->image)) { imagedestroy($this->image); } $image = imagecreatefromstring($data); - if (!$image instanceof \GdImage) { + if (!$this->isValidImageResource($image)) { throw new RuntimeException('Cannot create image from data string'); } @@ -124,7 +125,7 @@ class ImageManipulator */ public function resample(int $width, int $height, bool $constrainProportions = true): self { - if (!isset($this->image) || !$this->image instanceof \GdImage) { + if (!isset($this->image) || !$this->isValidImageResource($this->image)) { throw new RuntimeException('No image set'); } @@ -169,7 +170,7 @@ class ImageManipulator */ public function enlargeCanvas(int $width, int $height, array $rgb = [], ?int $xpos = null, ?int $ypos = null): self { - if (!isset($this->image) || !$this->image instanceof \GdImage) { + if (!isset($this->image) || !$this->isValidImageResource($this->image)) { throw new RuntimeException('No image set'); } @@ -177,7 +178,7 @@ class ImageManipulator $height = max($height, $this->height); $temp = imagecreatetruecolor($width, $height); - if (!$temp instanceof \GdImage) { + if (!$this->isValidImageResource($temp)) { throw new RuntimeException('Failed to create a new image for enlarging canvas'); } @@ -233,7 +234,7 @@ class ImageManipulator */ public function crop($x1, int $y1 = 0, int $x2 = 0, int $y2 = 0): self { - if (!isset($this->image) || !$this->image instanceof \GdImage) { + if (!isset($this->image) || !$this->isValidImageResource($this->image)) { throw new RuntimeException('No image set'); } @@ -257,7 +258,7 @@ class ImageManipulator } $temp = imagecreatetruecolor($cropWidth, $cropHeight); - if (!$temp instanceof \GdImage) { + if (!$this->isValidImageResource($temp)) { throw new RuntimeException('Failed to create a new image for cropping'); } @@ -286,17 +287,17 @@ class ImageManipulator /** * Replace current image resource with a new one * - * @param \GdImage $res New image resource + * @param resource|\GdImage $res New image resource * @return self * @throws UnexpectedValueException */ - protected function _replace(\GdImage $res): self + protected function _replace($res): self { - if (!$res instanceof \GdImage) { + if (!$this->isValidImageResource($res)) { throw new UnexpectedValueException('Invalid image resource'); } - if (isset($this->image) && $this->image instanceof \GdImage) { + if (isset($this->image) && $this->isValidImageResource($this->image)) { imagedestroy($this->image); } @@ -386,9 +387,9 @@ class ImageManipulator /** * Returns the GD image resource * - * @return \GdImage + * @return resource|\GdImage */ - public function getResource(): \GdImage + public function getResource() { return $this->image; } @@ -418,8 +419,22 @@ class ImageManipulator */ public function __destruct() { - if (isset($this->image) && $this->image instanceof \GdImage) { + if (isset($this->image) && $this->isValidImageResource($this->image)) { imagedestroy($this->image); } } + + /** + * Compatibility helper for PHP 7.4 (resource) and PHP 8+ (GdImage). + * + * @param mixed $image + */ + private function isValidImageResource($image): bool + { + if (is_resource($image)) { + return true; + } + + return class_exists('GdImage', false) && $image instanceof \GdImage; + } } diff --git a/libraries/grid/config.php b/libraries/grid/config.php index b6d615b..a04d21a 100644 --- a/libraries/grid/config.php +++ b/libraries/grid/config.php @@ -12,10 +12,21 @@ session_start(); /* połączenie z bazą ustawić wg własnych preferencji */ require_once dirname( __FILE__ ) . '/../../config.php'; require_once dirname( __FILE__ ) . '/../../autoload/class.S.php'; -require_once dirname( __FILE__ ) . '/../../autoload/admin/factory/class.Articles.php'; -require_once dirname( __FILE__ ) . '/../../autoload/admin/factory/class.Pages.php'; -require_once dirname( __FILE__ ) . '/../../autoload/admin/factory/class.ShopCategory.php'; -require_once dirname( __FILE__ ) . '/../../autoload/admin/factory/class.ShopProduct.php'; + +$legacyFactoryFiles = [ + '/../../autoload/admin/factory/class.Articles.php', + '/../../autoload/admin/factory/class.Pages.php', + '/../../autoload/admin/factory/class.ShopCategory.php', + '/../../autoload/admin/factory/class.ShopProduct.php', +]; + +foreach ( $legacyFactoryFiles as $legacyFactoryFile ) +{ + $legacyFactoryPath = dirname( __FILE__ ) . $legacyFactoryFile; + if ( file_exists( $legacyFactoryPath ) ) + require_once $legacyFactoryPath; +} + require_once dirname( __FILE__ ) . '/../../autoload/shop/class.Product.php'; require_once dirname( __FILE__ ) . '/../../libraries/medoo/medoo.php'; @@ -35,4 +46,4 @@ $mdb = new medoo( [ 'username' => $database['user'], 'password' => $database['password'], 'charset' => 'utf8' - ] ); \ No newline at end of file + ] ); diff --git a/libraries/plupload/upload-articles-files.php b/libraries/plupload/upload-articles-files.php index de93762..10b547c 100644 --- a/libraries/plupload/upload-articles-files.php +++ b/libraries/plupload/upload-articles-files.php @@ -1,154 +1,53 @@ 'Brak tokenu uploadu'] ); - exit; +plupload_bootstrap(); +plupload_require_post(); +$userId = plupload_require_admin_user(); +plupload_validate_token($userId); + +$fileDir = '/upload/article_files/tmp'; +$targetDir = '../..' . $fileDir; +plupload_ensure_target_dir($targetDir); + +list($chunk, $chunks) = plupload_get_chunks(); +list($fileName, $extension, $filePath, $partPath) = plupload_build_target_paths( + $targetDir, + $_REQUEST['name'] ?? '', + null, + [ + 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', + 'cgi', 'pl', 'py', 'rb', + 'asp', 'aspx', 'jsp', + 'js', 'mjs', 'vbs', 'wsf', 'hta', + 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'com', + 'exe', 'msi', 'scr', 'dll', 'jar', + ] +); + +plupload_cleanup_stale_parts($targetDir, $partPath, 5 * 3600); +plupload_write_chunk_to_part($partPath, $chunk); +plupload_assert_size_limit($partPath, 50 * 1024 * 1024, 'Plik przekracza dozwolony rozmiar (50 MB).'); + +$fileId = null; +$responseFileName = $fileName; +if (plupload_is_last_chunk($chunk, $chunks)) { + plupload_finalize_part($partPath, $filePath); + + $mdb = plupload_create_medoo($database); + $mdb->insert('pp_articles_files', [ + 'article_id' => null, + 'src' => substr($filePath, 5), + ]); + + $fileId = (int)$mdb->id(); + $responseFileName = basename($filePath); } -$tokenData = $_SESSION['upload_tokens'][$upload_token]; -if ( $tokenData['expires'] < time() ) { - unset( $_SESSION['upload_tokens'][$upload_token] ); - http_response_code(403); - echo json_encode( ['error' => 'Token wygasł'] ); - exit; -} +plupload_send_success([ + 'file_name' => $responseFileName, + 'file_id' => $fileId, +]); -$mdb = new medoo( [ - 'database_type' => 'mysql', - 'database_name' => $database['name'], - 'server' => $database['host'], - 'username' => $database['user'], - 'password' => $database['password'], - 'charset' => 'utf8' - ] ); - -header( "Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); -header( "Last-Modified: " . gmdate( "D, d M Y H:i:s" ) . " GMT" ); -header( "Cache-Control: no-store, no-cache, must-revalidate" ); -header( "Cache-Control: post-check=0, pre-check=0", false ); -header( "Pragma: no-cache" ); - -$fileDir = '/upload/article_files/tmp'; -$targetDir = '../..' . $fileDir; - -if ( !is_dir( $targetDir ) ) - mkdir( $targetDir, 0755, true ); - -$cleanupTargetDir = true; -$maxFileAge = 5 * 3600; - -$chunk = isset( $_REQUEST["chunk"] ) ? intval( $_REQUEST["chunk"] ) : 0; -$chunks = isset( $_REQUEST["chunks"] ) ? intval( $_REQUEST["chunks"] ) : 0; -$fileName = isset( $_REQUEST["name"] ) ? $_REQUEST["name"] : ''; - -$fileName = preg_replace( '/[^\w\._]+/', '-', $fileName ); - -if ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName ) ) -{ - $ext = strrpos( $fileName, '.' ); - $fileName_a = substr( $fileName, 0, $ext ); - $fileName_b = substr( $fileName, $ext ); - - $count = 1; - - while ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName_a . '_' . $count . $fileName_b ) ) - $count++; - - $fileName = $fileName_a . '_' . $count . $fileName_b; -} - -$filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName; - -if ( $cleanupTargetDir && is_dir( $targetDir ) && ( $dir = opendir( $targetDir ) ) ) -{ - while ( ( $file = readdir( $dir ) ) !== false ) - { - $tmpfilePath = $targetDir . DIRECTORY_SEPARATOR . $file; - - if ( preg_match( '/\.part$/', $file ) && ( filemtime( $tmpfilePath ) < time() - $maxFileAge ) && ( $tmpfilePath != "{$filePath}.part" ) ) { - @unlink( $tmpfilePath ); - } - } - - closedir($dir); -} -else - die( '{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}' ); - -if ( isset( $_SERVER["HTTP_CONTENT_TYPE"] ) ) - $contentType = $_SERVER["HTTP_CONTENT_TYPE"]; - -if ( isset( $_SERVER["CONTENT_TYPE"] ) ) - $contentType = $_SERVER["CONTENT_TYPE"]; - -if ( strpos( $contentType, "multipart" ) !== false ) -{ - if ( isset( $_FILES['file']['tmp_name'] ) && is_uploaded_file( $_FILES['file']['tmp_name'] ) ) - { - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( $_FILES['file']['tmp_name'], "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite($out, $buff); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - fclose( $in ); - fclose( $out ); - @unlink( $_FILES['file']['tmp_name'] ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}' ); -} -else -{ - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( "php://input", "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite( $out, $buff ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - - fclose( $in ); - fclose( $out ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); -} - -if ( !$chunks || $chunk == $chunks - 1 ) -{ - rename( "{$filePath}.part", $filePath ); - - $mdb -> insert( 'pp_articles_files', [ - 'article_id' => null, - 'src' => substr( $filePath, 5, strlen( $filePath ) ) - ] ); - - $file_id = $mdb -> id(); - - $file_name = explode( '/', $filePath ); - $file_name = $file_name[ count( $file_name ) - 1 ]; -} - -die( '{"jsonrpc" : "2.0", "result" : null, "id" : "id", "file_name" : "' . $file_name . '", "file_id" : "' . $file_id . '"}' ); -?> \ No newline at end of file diff --git a/libraries/plupload/upload-articles-images.php b/libraries/plupload/upload-articles-images.php index b2c6dad..d0d90c7 100644 --- a/libraries/plupload/upload-articles-images.php +++ b/libraries/plupload/upload-articles-images.php @@ -1,153 +1,61 @@ 'Brak tokenu uploadu'] ); - exit; +plupload_bootstrap(); +plupload_require_post(); +$userId = plupload_require_admin_user(); +plupload_validate_token($userId); + +$fileDir = '/upload/article_images/tmp'; +$targetDir = '../..' . $fileDir; +plupload_ensure_target_dir($targetDir); + +list($chunk, $chunks) = plupload_get_chunks(); +list($fileName, $extension, $filePath, $partPath) = plupload_build_target_paths( + $targetDir, + $_REQUEST['name'] ?? '', + ['jpg', 'jpeg', 'png', 'gif', 'webp'], + null +); + +plupload_cleanup_stale_parts($targetDir, $partPath, 5 * 3600); +plupload_write_chunk_to_part($partPath, $chunk); +plupload_assert_size_limit($partPath, 20 * 1024 * 1024, 'Plik przekracza dozwolony rozmiar (20 MB).'); + +$imageId = null; +if (plupload_is_last_chunk($chunk, $chunks)) { + plupload_finalize_part($partPath, $filePath); + + $mime = mime_content_type($filePath) ?: ''; + $imageMeta = @getimagesize($filePath); + $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $isValidImage = in_array($mime, $allowedMimeTypes, true) + && is_array($imageMeta) + && (int)($imageMeta[0] ?? 0) > 0 + && (int)($imageMeta[1] ?? 0) > 0; + + if (!$isValidImage) { + @unlink($filePath); + plupload_send_error(400, 601, 'Plik nie jest prawidlowym obrazem.'); + } + + $mdb = plupload_create_medoo($database); + $order = (int)$mdb->max('pp_articles_images', 'o'); + $articleId = (int)($_POST['article_id'] ?? 0); + + $mdb->insert('pp_articles_images', [ + 'article_id' => $articleId > 0 ? $articleId : null, + 'src' => substr($filePath, 5), + 'o' => $order + 1, + ]); + + $imageId = (int)$mdb->id(); } -$tokenData = $_SESSION['upload_tokens'][$upload_token]; -if ( $tokenData['expires'] < time() ) { - unset( $_SESSION['upload_tokens'][$upload_token] ); - http_response_code(403); - echo json_encode( ['error' => 'Token wygasł'] ); - exit; -} +plupload_send_success([ + 'data_link' => str_replace('../../', '', $filePath), + 'image_id' => $imageId, +]); -$mdb = new medoo( [ - 'database_type' => 'mysql', - 'database_name' => $database['name'], - 'server' => $database['host'], - 'username' => $database['user'], - 'password' => $database['password'], - 'charset' => 'utf8' - ] ); - -header( "Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); -header( "Last-Modified: " . gmdate( "D, d M Y H:i:s" ) . " GMT" ); -header( "Cache-Control: no-store, no-cache, must-revalidate" ); -header( "Cache-Control: post-check=0, pre-check=0", false ); -header( "Pragma: no-cache" ); - -$fileDir = '/upload/article_images/tmp'; -$targetDir = '../..' . $fileDir; - -if ( !is_dir( $targetDir ) ) - mkdir( $targetDir, 0755, true ); - -$cleanupTargetDir = true; -$maxFileAge = 5 * 3600; - -$chunk = isset( $_REQUEST["chunk"] ) ? intval( $_REQUEST["chunk"] ) : 0; -$chunks = isset( $_REQUEST["chunks"] ) ? intval( $_REQUEST["chunks"] ) : 0; -$fileName = isset( $_REQUEST["name"] ) ? $_REQUEST["name"] : ''; - -$fileName = preg_replace( '/[^\w\._]+/', '-', $fileName ); - -if ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName ) ) -{ - $ext = strrpos( $fileName, '.' ); - $fileName_a = substr( $fileName, 0, $ext ); - $fileName_b = substr( $fileName, $ext ); - - $count = 1; - - while ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName_a . '_' . $count . $fileName_b ) ) - $count++; - - $fileName = $fileName_a . '_' . $count . $fileName_b; -} - -$filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName; - -if ( $cleanupTargetDir && is_dir( $targetDir ) && ( $dir = opendir( $targetDir ) ) ) -{ - while ( ( $file = readdir( $dir ) ) !== false ) - { - $tmpfilePath = $targetDir . DIRECTORY_SEPARATOR . $file; - - if ( preg_match( '/\.part$/', $file ) && ( filemtime( $tmpfilePath ) < time() - $maxFileAge ) && ( $tmpfilePath != "{$filePath}.part" ) ) { - @unlink( $tmpfilePath ); - } - } - - closedir($dir); -} -else - die( '{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}' ); - -if ( isset( $_SERVER["HTTP_CONTENT_TYPE"] ) ) - $contentType = $_SERVER["HTTP_CONTENT_TYPE"]; - -if ( isset( $_SERVER["CONTENT_TYPE"] ) ) - $contentType = $_SERVER["CONTENT_TYPE"]; - -if ( strpos( $contentType, "multipart" ) !== false ) -{ - if ( isset( $_FILES['file']['tmp_name'] ) && is_uploaded_file( $_FILES['file']['tmp_name'] ) ) - { - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( $_FILES['file']['tmp_name'], "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite($out, $buff); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - fclose( $in ); - fclose( $out ); - @unlink( $_FILES['file']['tmp_name'] ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}' ); -} -else -{ - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( "php://input", "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite( $out, $buff ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - - fclose( $in ); - fclose( $out ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); -} - -if ( !$chunks || $chunk == $chunks - 1 ) -{ - rename( "{$filePath}.part", $filePath ); - - $o = $mdb -> max( 'pp_articles_images', 'o' ); - - $mdb -> insert( 'pp_articles_images', [ - 'article_id' => $_POST['article_id'] ? $_POST['article_id'] : null, - 'src' => substr( $filePath, 5, strlen( $filePath ) ), - 'o' => ++$o - ] ); - $image_id = $mdb -> id(); -} - -die( '{"jsonrpc" : "2.0", "result" : null, "id" : "id", "data_link" : "' . str_replace( '../../', '', $filePath ) . '", "image_id" : "' . $image_id . '"}' ); -?> \ No newline at end of file diff --git a/libraries/plupload/upload-common.php b/libraries/plupload/upload-common.php new file mode 100644 index 0000000..d8fa95d --- /dev/null +++ b/libraries/plupload/upload-common.php @@ -0,0 +1,307 @@ + '2.0', + 'error' => [ + 'code' => (int)$code, + 'message' => (string)$message, + ], + 'id' => 'id', + ]); + exit; + } +} + +if (!function_exists('plupload_bootstrap')) { + function plupload_bootstrap() + { + date_default_timezone_set('Europe/Warsaw'); + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + header('Content-Type: application/json; charset=utf-8'); + header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); + header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + } +} + +if (!function_exists('plupload_require_post')) { + function plupload_require_post() + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + plupload_send_error(405, 405, 'Method not allowed.'); + } + } +} + +if (!function_exists('plupload_require_admin_user')) { + function plupload_require_admin_user() + { + $adminSession = isset($_SESSION['admin']) && $_SESSION['admin'] === true; + $userId = (int)($_SESSION['user']['id'] ?? 0); + + if (!$adminSession || $userId <= 0) { + plupload_send_error(403, 403, 'Brak autoryzacji.'); + } + + $sessionOk = isset($_SESSION['check'], $_SESSION['ip']) + && $_SESSION['check'] === true + && $_SESSION['ip'] === ($_SERVER['REMOTE_ADDR'] ?? ''); + + if (!$sessionOk) { + plupload_send_error(403, 403, 'Nieprawidlowa sesja.'); + } + + return $userId; + } +} + +if (!function_exists('plupload_validate_token')) { + function plupload_validate_token($userId) + { + $uploadToken = (string)($_REQUEST['upload_token'] ?? ''); + if ($uploadToken === '' || !isset($_SESSION['upload_tokens'][$uploadToken])) { + plupload_send_error(403, 403, 'Brak tokenu uploadu.'); + } + + $tokenData = $_SESSION['upload_tokens'][$uploadToken]; + $tokenUserId = (int)($tokenData['user_id'] ?? 0); + $tokenExpires = (int)($tokenData['expires'] ?? 0); + + if ($tokenUserId <= 0 || $tokenUserId !== (int)$userId) { + plupload_send_error(403, 403, 'Token nie nalezy do aktualnego uzytkownika.'); + } + + if ($tokenExpires < time()) { + unset($_SESSION['upload_tokens'][$uploadToken]); + plupload_send_error(403, 403, 'Token wygasl.'); + } + + return [$uploadToken, $tokenData]; + } +} + +if (!function_exists('plupload_normalize_filename')) { + function plupload_normalize_filename($fileName) + { + $fileName = basename((string)$fileName); + $fileName = preg_replace('/[^\w\.-]+/', '-', $fileName); + $fileName = trim((string)$fileName, '.-'); + + if ($fileName === '') { + $fileName = 'file-' . bin2hex(random_bytes(8)); + } + + return strtolower($fileName); + } +} + +if (!function_exists('plupload_ensure_target_dir')) { + function plupload_ensure_target_dir($targetDir) + { + if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) { + plupload_send_error(500, 100, 'Failed to open temp directory.'); + } + } +} + +if (!function_exists('plupload_build_target_paths')) { + function plupload_build_target_paths($targetDir, $requestName, $allowedExtensions = null, $blockedExtensions = null, $maxNameLength = 180) + { + $fileName = plupload_normalize_filename((string)$requestName); + $extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)); + + if (is_array($allowedExtensions)) { + if ($extension === '' || !in_array($extension, $allowedExtensions, true)) { + plupload_send_error(400, 601, 'Nieobslugiwane rozszerzenie pliku.'); + } + } + + if (is_array($blockedExtensions)) { + if ($extension !== '' && in_array($extension, $blockedExtensions, true)) { + plupload_send_error(400, 601, 'Rozszerzenie pliku jest zablokowane.'); + } + } + + if (strlen($fileName) > (int)$maxNameLength) { + $base = substr((string)pathinfo($fileName, PATHINFO_FILENAME), 0, 140); + $suffix = '-' . bin2hex(random_bytes(4)); + $fileName = $base . $suffix . ($extension !== '' ? '.' . $extension : ''); + } + + if (file_exists($targetDir . DIRECTORY_SEPARATOR . $fileName)) { + $nameWithoutExt = (string)pathinfo($fileName, PATHINFO_FILENAME); + $extWithDot = $extension !== '' ? '.' . $extension : ''; + $count = 1; + + while (file_exists($targetDir . DIRECTORY_SEPARATOR . $nameWithoutExt . '_' . $count . $extWithDot)) { + $count++; + } + + $fileName = $nameWithoutExt . '_' . $count . $extWithDot; + } + + $filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName; + $partPath = $filePath . '.part'; + + return [$fileName, $extension, $filePath, $partPath]; + } +} + +if (!function_exists('plupload_get_chunks')) { + function plupload_get_chunks() + { + $chunk = max(0, (int)($_REQUEST['chunk'] ?? 0)); + $chunks = max(0, (int)($_REQUEST['chunks'] ?? 0)); + return [$chunk, $chunks]; + } +} + +if (!function_exists('plupload_cleanup_stale_parts')) { + function plupload_cleanup_stale_parts($targetDir, $currentPartPath, $maxFileAge = 18000) + { + $dir = @opendir($targetDir); + if (!$dir) { + return; + } + + while (($file = readdir($dir)) !== false) { + $tmpFilePath = $targetDir . DIRECTORY_SEPARATOR . $file; + if (!preg_match('/\.part$/', $file)) { + continue; + } + + if ($tmpFilePath === $currentPartPath) { + continue; + } + + if (@filemtime($tmpFilePath) < (time() - (int)$maxFileAge)) { + @unlink($tmpFilePath); + } + } + + closedir($dir); + } +} + +if (!function_exists('plupload_write_chunk_to_part')) { + function plupload_write_chunk_to_part($partPath, $chunk) + { + $contentType = (string)($_SERVER['HTTP_CONTENT_TYPE'] ?? $_SERVER['CONTENT_TYPE'] ?? ''); + $isMultipart = strpos($contentType, 'multipart') !== false; + + if ($isMultipart) { + $fileInfo = $_FILES['file'] ?? null; + if (!is_array($fileInfo) || !isset($fileInfo['tmp_name']) || !is_uploaded_file($fileInfo['tmp_name'])) { + plupload_send_error(400, 103, 'Failed to move uploaded file.'); + } + + if ((int)($fileInfo['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + plupload_send_error(400, 104, 'Upload error.'); + } + + $in = fopen($fileInfo['tmp_name'], 'rb'); + $out = fopen($partPath, ((int)$chunk === 0) ? 'wb' : 'ab'); + + if (!$in) { + plupload_send_error(500, 101, 'Failed to open input stream.'); + } + + if (!$out) { + fclose($in); + plupload_send_error(500, 102, 'Failed to open output stream.'); + } + + while ($buff = fread($in, 4096)) { + fwrite($out, $buff); + } + + fclose($in); + fclose($out); + @unlink($fileInfo['tmp_name']); + return; + } + + $in = fopen('php://input', 'rb'); + $out = fopen($partPath, ((int)$chunk === 0) ? 'wb' : 'ab'); + + if (!$in) { + plupload_send_error(500, 101, 'Failed to open input stream.'); + } + + if (!$out) { + fclose($in); + plupload_send_error(500, 102, 'Failed to open output stream.'); + } + + while ($buff = fread($in, 4096)) { + fwrite($out, $buff); + } + + fclose($in); + fclose($out); + } +} + +if (!function_exists('plupload_assert_size_limit')) { + function plupload_assert_size_limit($partPath, $maxBytes, $message) + { + if (@filesize($partPath) > (int)$maxBytes) { + @unlink($partPath); + plupload_send_error(413, 413, (string)$message); + } + } +} + +if (!function_exists('plupload_is_last_chunk')) { + function plupload_is_last_chunk($chunk, $chunks) + { + return ((int)$chunks === 0) || ((int)$chunk === ((int)$chunks - 1)); + } +} + +if (!function_exists('plupload_finalize_part')) { + function plupload_finalize_part($partPath, $filePath) + { + if (!@rename($partPath, $filePath)) { + @unlink($partPath); + plupload_send_error(500, 105, 'Failed to finalize uploaded file.'); + } + } +} + +if (!function_exists('plupload_create_medoo')) { + function plupload_create_medoo($database) + { + return new medoo([ + 'database_type' => 'mysql', + 'database_name' => $database['name'], + 'server' => $database['host'], + 'username' => $database['user'], + 'password' => $database['password'], + 'charset' => 'utf8', + ]); + } +} + +if (!function_exists('plupload_send_success')) { + function plupload_send_success(array $payload) + { + echo json_encode(array_merge([ + 'jsonrpc' => '2.0', + 'result' => null, + 'id' => 'id', + ], $payload)); + exit; + } +} + diff --git a/libraries/plupload/upload-product-files.php b/libraries/plupload/upload-product-files.php index 735b9bf..49bdaab 100644 --- a/libraries/plupload/upload-product-files.php +++ b/libraries/plupload/upload-product-files.php @@ -1,154 +1,53 @@ 'Brak tokenu uploadu'] ); - exit; +plupload_bootstrap(); +plupload_require_post(); +$userId = plupload_require_admin_user(); +plupload_validate_token($userId); + +$fileDir = '/upload/product_files/tmp'; +$targetDir = '../..' . $fileDir; +plupload_ensure_target_dir($targetDir); + +list($chunk, $chunks) = plupload_get_chunks(); +list($fileName, $extension, $filePath, $partPath) = plupload_build_target_paths( + $targetDir, + $_REQUEST['name'] ?? '', + null, + [ + 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', + 'cgi', 'pl', 'py', 'rb', + 'asp', 'aspx', 'jsp', + 'js', 'mjs', 'vbs', 'wsf', 'hta', + 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'com', + 'exe', 'msi', 'scr', 'dll', 'jar', + ] +); + +plupload_cleanup_stale_parts($targetDir, $partPath, 5 * 3600); +plupload_write_chunk_to_part($partPath, $chunk); +plupload_assert_size_limit($partPath, 50 * 1024 * 1024, 'Plik przekracza dozwolony rozmiar (50 MB).'); + +$fileId = null; +$responseFileName = $fileName; +if (plupload_is_last_chunk($chunk, $chunks)) { + plupload_finalize_part($partPath, $filePath); + + $mdb = plupload_create_medoo($database); + $mdb->insert('pp_shop_products_files', [ + 'product_id' => null, + 'src' => substr($filePath, 5), + ]); + + $fileId = (int)$mdb->id(); + $responseFileName = basename($filePath); } -$tokenData = $_SESSION['upload_tokens'][$upload_token]; -if ( $tokenData['expires'] < time() ) { - unset( $_SESSION['upload_tokens'][$upload_token] ); - http_response_code(403); - echo json_encode( ['error' => 'Token wygasł'] ); - exit; -} +plupload_send_success([ + 'file_name' => $responseFileName, + 'file_id' => $fileId, +]); -$mdb = new medoo( [ - 'database_type' => 'mysql', - 'database_name' => $database['name'], - 'server' => $database['host'], - 'username' => $database['user'], - 'password' => $database['password'], - 'charset' => 'utf8' - ] ); - -header( "Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); -header( "Last-Modified: " . gmdate( "D, d M Y H:i:s" ) . " GMT" ); -header( "Cache-Control: no-store, no-cache, must-revalidate" ); -header( "Cache-Control: post-check=0, pre-check=0", false ); -header( "Pragma: no-cache" ); - -$fileDir = '/upload/product_files/tmp'; -$targetDir = '../..' . $fileDir; - -if ( !is_dir( $targetDir ) ) - mkdir( $targetDir, 0755, true ); - -$cleanupTargetDir = true; -$maxFileAge = 5 * 3600; - -$chunk = isset( $_REQUEST["chunk"] ) ? intval( $_REQUEST["chunk"] ) : 0; -$chunks = isset( $_REQUEST["chunks"] ) ? intval( $_REQUEST["chunks"] ) : 0; -$fileName = isset( $_REQUEST["name"] ) ? $_REQUEST["name"] : ''; - -$fileName = preg_replace( '/[^\w\._]+/', '-', $fileName ); - -if ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName ) ) -{ - $ext = strrpos( $fileName, '.' ); - $fileName_a = substr( $fileName, 0, $ext ); - $fileName_b = substr( $fileName, $ext ); - - $count = 1; - - while ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName_a . '_' . $count . $fileName_b ) ) - $count++; - - $fileName = $fileName_a . '_' . $count . $fileName_b; -} - -$filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName; - -if ( $cleanupTargetDir && is_dir( $targetDir ) && ( $dir = opendir( $targetDir ) ) ) -{ - while ( ( $file = readdir( $dir ) ) !== false ) - { - $tmpfilePath = $targetDir . DIRECTORY_SEPARATOR . $file; - - if ( preg_match( '/\.part$/', $file ) && ( filemtime( $tmpfilePath ) < time() - $maxFileAge ) && ( $tmpfilePath != "{$filePath}.part" ) ) { - @unlink( $tmpfilePath ); - } - } - - closedir($dir); -} -else - die( '{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}' ); - -if ( isset( $_SERVER["HTTP_CONTENT_TYPE"] ) ) - $contentType = $_SERVER["HTTP_CONTENT_TYPE"]; - -if ( isset( $_SERVER["CONTENT_TYPE"] ) ) - $contentType = $_SERVER["CONTENT_TYPE"]; - -if ( strpos( $contentType, "multipart" ) !== false ) -{ - if ( isset( $_FILES['file']['tmp_name'] ) && is_uploaded_file( $_FILES['file']['tmp_name'] ) ) - { - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( $_FILES['file']['tmp_name'], "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite($out, $buff); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - fclose( $in ); - fclose( $out ); - @unlink( $_FILES['file']['tmp_name'] ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}' ); -} -else -{ - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( "php://input", "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite( $out, $buff ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - - fclose( $in ); - fclose( $out ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); -} - -if ( !$chunks || $chunk == $chunks - 1 ) -{ - rename( "{$filePath}.part", $filePath ); - - $mdb -> insert( 'pp_shop_products_files', [ - 'product_id' => null, - 'src' => substr( $filePath, 5, strlen( $filePath ) ) - ] ); - - $file_id = $mdb -> id(); - - $file_name = explode( '/', $filePath ); - $file_name = $file_name[ count( $file_name ) - 1 ]; -} - -die( '{"jsonrpc" : "2.0", "result" : null, "id" : "id", "file_name" : "' . $file_name . '", "file_id" : "' . $file_id . '"}' ); -?> \ No newline at end of file diff --git a/libraries/plupload/upload-product-images.php b/libraries/plupload/upload-product-images.php index 88894b9..04be8cb 100644 --- a/libraries/plupload/upload-product-images.php +++ b/libraries/plupload/upload-product-images.php @@ -1,154 +1,61 @@ 'Brak tokenu uploadu'] ); - exit; +plupload_bootstrap(); +plupload_require_post(); +$userId = plupload_require_admin_user(); +plupload_validate_token($userId); + +$fileDir = '/upload/product_images/tmp'; +$targetDir = '../..' . $fileDir; +plupload_ensure_target_dir($targetDir); + +list($chunk, $chunks) = plupload_get_chunks(); +list($fileName, $extension, $filePath, $partPath) = plupload_build_target_paths( + $targetDir, + $_REQUEST['name'] ?? '', + ['jpg', 'jpeg', 'png', 'gif', 'webp'], + null +); + +plupload_cleanup_stale_parts($targetDir, $partPath, 5 * 3600); +plupload_write_chunk_to_part($partPath, $chunk); +plupload_assert_size_limit($partPath, 20 * 1024 * 1024, 'Plik przekracza dozwolony rozmiar (20 MB).'); + +$imageId = null; +if (plupload_is_last_chunk($chunk, $chunks)) { + plupload_finalize_part($partPath, $filePath); + + $mime = mime_content_type($filePath) ?: ''; + $imageMeta = @getimagesize($filePath); + $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $isValidImage = in_array($mime, $allowedMimeTypes, true) + && is_array($imageMeta) + && (int)($imageMeta[0] ?? 0) > 0 + && (int)($imageMeta[1] ?? 0) > 0; + + if (!$isValidImage) { + @unlink($filePath); + plupload_send_error(400, 601, 'Plik nie jest prawidlowym obrazem.'); + } + + $mdb = plupload_create_medoo($database); + $order = (int)$mdb->max('pp_shop_products_images', 'o'); + $productId = (int)($_POST['product_id'] ?? 0); + + $mdb->insert('pp_shop_products_images', [ + 'product_id' => $productId > 0 ? $productId : null, + 'src' => substr($filePath, 5), + 'o' => $order + 1, + ]); + + $imageId = (int)$mdb->id(); } -$tokenData = $_SESSION['upload_tokens'][$upload_token]; -if ( $tokenData['expires'] < time() ) { - unset( $_SESSION['upload_tokens'][$upload_token] ); - http_response_code(403); - echo json_encode( ['error' => 'Token wygasł'] ); - exit; -} +plupload_send_success([ + 'data_link' => str_replace('../../', '', $filePath), + 'image_id' => $imageId, +]); -$mdb = new medoo( [ - 'database_type' => 'mysql', - 'database_name' => $database['name'], - 'server' => $database['host'], - 'username' => $database['user'], - 'password' => $database['password'], - 'charset' => 'utf8' - ] ); - -header( "Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); -header( "Last-Modified: " . gmdate( "D, d M Y H:i:s" ) . " GMT" ); -header( "Cache-Control: no-store, no-cache, must-revalidate" ); -header( "Cache-Control: post-check=0, pre-check=0", false ); -header( "Pragma: no-cache" ); - -$fileDir = '/upload/product_images/tmp'; -$targetDir = '../..' . $fileDir; - -if ( !is_dir( $targetDir ) ) - mkdir( $targetDir, 0755, true ); - -$cleanupTargetDir = true; -$maxFileAge = 5 * 3600; - -$chunk = isset( $_REQUEST["chunk"] ) ? intval( $_REQUEST["chunk"] ) : 0; -$chunks = isset( $_REQUEST["chunks"] ) ? intval( $_REQUEST["chunks"] ) : 0; -$fileName = isset( $_REQUEST["name"] ) ? $_REQUEST["name"] : ''; - -$fileName = preg_replace( '/[^\w\._]+/', '-', $fileName ); - -if ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName ) ) -{ - $ext = strrpos( $fileName, '.' ); - $fileName_a = substr( $fileName, 0, $ext ); - $fileName_b = substr( $fileName, $ext ); - - $count = 1; - - while ( file_exists( $targetDir . DIRECTORY_SEPARATOR . $fileName_a . '_' . $count . $fileName_b ) ) - $count++; - - $fileName = $fileName_a . '_' . $count . $fileName_b; -} - -$filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName; - -if ( $cleanupTargetDir && is_dir( $targetDir ) && ( $dir = opendir( $targetDir ) ) ) -{ - while ( ( $file = readdir( $dir ) ) !== false ) - { - $tmpfilePath = $targetDir . DIRECTORY_SEPARATOR . $file; - - if ( preg_match( '/\.part$/', $file ) && ( filemtime( $tmpfilePath ) < time() - $maxFileAge ) && ( $tmpfilePath != "{$filePath}.part" ) ) { - @unlink( $tmpfilePath ); - } - } - - closedir($dir); -} -else - die( '{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}' ); - -if ( isset( $_SERVER["HTTP_CONTENT_TYPE"] ) ) - $contentType = $_SERVER["HTTP_CONTENT_TYPE"]; - -if ( isset( $_SERVER["CONTENT_TYPE"] ) ) - $contentType = $_SERVER["CONTENT_TYPE"]; - -if ( strpos( $contentType, "multipart" ) !== false ) -{ - if ( isset( $_FILES['file']['tmp_name'] ) && is_uploaded_file( $_FILES['file']['tmp_name'] ) ) - { - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( $_FILES['file']['tmp_name'], "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite($out, $buff); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - fclose( $in ); - fclose( $out ); - @unlink( $_FILES['file']['tmp_name'] ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}' ); -} -else -{ - $out = fopen( "{$filePath}.part", $chunk == 0 ? "wb" : "ab" ); - if ( $out ) - { - $in = fopen( "php://input", "rb" ); - - if ( $in ) - { - while ( $buff = fread( $in, 4096 ) ) - fwrite( $out, $buff ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}' ); - - fclose( $in ); - fclose( $out ); - } - else - die( '{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}' ); -} - -if ( !$chunks || $chunk == $chunks - 1 ) -{ - rename( "{$filePath}.part", $filePath ); - - $o = $mdb -> max( 'pp_shop_products_images', 'o' ); - - $mdb -> insert( 'pp_shop_products_images', [ - 'product_id' => isset( $_POST['product_id'] ) ? $_POST['product_id'] : null, - 'src' => substr( $filePath, 5, strlen( $filePath ) ), - 'o' => ++$o - ] ); - - $image_id = $mdb -> id(); -} - -die( '{"jsonrpc" : "2.0", "result" : null, "id" : "id", "data_link" : "' . str_replace( '../../', '', $filePath ) . '", "image_id" : "' . $image_id . '"}' ); -?> \ No newline at end of file diff --git a/tests/Unit/Domain/Article/ArticleRepositoryTest.php b/tests/Unit/Domain/Article/ArticleRepositoryTest.php index 06aea6a..333f818 100644 --- a/tests/Unit/Domain/Article/ArticleRepositoryTest.php +++ b/tests/Unit/Domain/Article/ArticleRepositoryTest.php @@ -413,6 +413,56 @@ class ArticleRepositoryTest extends TestCase $this->assertTrue($result); } + public function testSaveFilesOrderUpdatesFilesOrder(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->expects($this->exactly(3)) + ->method('update') + ->withConsecutive( + [ + 'pp_articles_files', + ['o' => 0], + ['AND' => ['article_id' => 12, 'id' => 70]] + ], + [ + 'pp_articles_files', + ['o' => 1], + ['AND' => ['article_id' => 12, 'id' => 71]] + ], + [ + 'pp_articles_files', + ['o' => 2], + ['AND' => ['article_id' => 12, 'id' => 72]] + ] + ) + ->willReturn(true); + + $repository = new ArticleRepository($mockDb); + $result = $repository->saveFilesOrder(12, '70;71;72'); + + $this->assertTrue($result); + } + + public function testSaveFilesOrderSkipsEmptyValues(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_articles_files', + ['o' => 0], + ['AND' => ['article_id' => 7, 'id' => 101]] + ) + ->willReturn(true); + + $repository = new ArticleRepository($mockDb); + $result = $repository->saveFilesOrder(7, ';101;'); + + $this->assertTrue($result); + } + public function testArchiveSetsStatusToMinusOne(): void { $mockDb = $this->createMock(\medoo::class); @@ -482,6 +532,56 @@ class ArticleRepositoryTest extends TestCase $this->assertSame('pp_articles', $deleteCalls[4]['table']); } + public function testPagesSummaryForArticlesBuildsLabels(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->expects($this->once()) + ->method('query') + ->willReturnCallback(function ($sql, $params = []) { + return new class { + public function fetchAll() + { + return [ + ['article_id' => 5, 'page_id' => 10, 'title' => 'Blog'], + ['article_id' => 5, 'page_id' => 11, 'title' => 'Poradniki'], + ['article_id' => 8, 'page_id' => 12, 'title' => 'Aktualnosci'], + ]; + } + }; + }); + + $repository = new ArticleRepository($mockDb); + $result = $repository->pagesSummaryForArticles([5, 8]); + + $this->assertSame(' - Blog / Poradniki', $result[5]); + $this->assertSame(' - Aktualnosci', $result[8]); + } + + public function testUpdateImageAltDelegatesToDatabase(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('update') + ->with('pp_articles_images', ['alt' => 'Nowy alt'], ['id' => 33]) + ->willReturn(true); + + $repository = new ArticleRepository($mockDb); + $this->assertTrue($repository->updateImageAlt(33, 'Nowy alt')); + } + + public function testMarkFileToDeleteDelegatesToDatabase(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('update') + ->with('pp_articles_files', ['to_delete' => 1], ['id' => 17]) + ->willReturn(true); + + $repository = new ArticleRepository($mockDb); + $this->assertTrue($repository->markFileToDelete(17)); + } + public function testListArchivedForAdminWhitelistsSortAndDirection(): void { $mockDb = $this->createMock(\medoo::class); diff --git a/tests/Unit/admin/Controllers/ArticlesControllerTest.php b/tests/Unit/admin/Controllers/ArticlesControllerTest.php index 15b92e2..4051585 100644 --- a/tests/Unit/admin/Controllers/ArticlesControllerTest.php +++ b/tests/Unit/admin/Controllers/ArticlesControllerTest.php @@ -56,6 +56,26 @@ class ArticlesControllerTest extends TestCase $this->assertTrue(method_exists($this->controller, 'galleryOrderSave')); } + public function testHasImageAltChangeMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'imageAltChange')); + } + + public function testHasFileNameChangeMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'fileNameChange')); + } + + public function testHasImageDeleteMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'imageDelete')); + } + + public function testHasFileDeleteMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'fileDelete')); + } + public function testListMethodReturnType(): void { $reflection = new \ReflectionClass($this->controller); @@ -74,6 +94,30 @@ class ArticlesControllerTest extends TestCase $this->assertEquals('void', (string)$reflection->getMethod('galleryOrderSave')->getReturnType()); } + public function testImageAltChangeMethodReturnType(): void + { + $reflection = new \ReflectionClass($this->controller); + $this->assertEquals('void', (string)$reflection->getMethod('imageAltChange')->getReturnType()); + } + + public function testFileNameChangeMethodReturnType(): void + { + $reflection = new \ReflectionClass($this->controller); + $this->assertEquals('void', (string)$reflection->getMethod('fileNameChange')->getReturnType()); + } + + public function testImageDeleteMethodReturnType(): void + { + $reflection = new \ReflectionClass($this->controller); + $this->assertEquals('void', (string)$reflection->getMethod('imageDelete')->getReturnType()); + } + + public function testFileDeleteMethodReturnType(): void + { + $reflection = new \ReflectionClass($this->controller); + $this->assertEquals('void', (string)$reflection->getMethod('fileDelete')->getReturnType()); + } + public function testConstructorRequiresArticleRepository(): void { $reflection = new \ReflectionClass(ArticlesController::class); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6cf9a68..ada8d9d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -44,6 +44,7 @@ if (!class_exists('S')) { public static function delete_dir($path) {} public static function alert($msg) {} public static function htacces() {} + public static function delete_cache() {} public static function get($key) { return null; } public static function set_message($msg) {} public static function clear_redis_cache() {} diff --git a/updates/0.20/ver_0.262.zip b/updates/0.20/ver_0.262.zip new file mode 100644 index 0000000000000000000000000000000000000000..e3fd16d112cd4d042778c1f23d89c2d63b531ce0 GIT binary patch literal 43164 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 l{GUhLHRay`|E0^nFwvFdP~mCL0005}p?n