Initial commit - CMS Gov Bapenda Garut dengan EditorJS

This commit is contained in:
2026-01-05 06:47:36 +07:00
commit bd649bd5f2
634 changed files with 215640 additions and 0 deletions

131
.gitignore vendored Normal file
View File

@@ -0,0 +1,131 @@
#-------------------------
# Operating Specific Junk Files
#-------------------------
# OS X
.DS_Store
.AppleDouble
.LSOverride
# OS X Thumbnails
._*
# Windows image file caches
Thumbs.db
ehthumbs.db
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
# Linux
*~
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
#-------------------------
# Environment Files
#-------------------------
# These should never be under version control,
# as it poses a security risk.
.env
.vagrant
Vagrantfile
#-------------------------
# Temporary Files
#-------------------------
writable/cache/*
!writable/cache/index.html
writable/logs/*
!writable/logs/index.html
writable/session/*
!writable/session/index.html
writable/uploads/*
!writable/uploads/index.html
writable/debugbar/*
!writable/debugbar/index.html
php_errors.log
#-------------------------
# User Guide Temp Files
#-------------------------
user_guide_src/build/*
user_guide_src/cilexer/build/*
user_guide_src/cilexer/dist/*
user_guide_src/cilexer/pycilexer.egg-info/*
#-------------------------
# Test Files
#-------------------------
tests/coverage*
# Don't save phpunit under version control.
phpunit
#-------------------------
# Composer
#-------------------------
vendor/
#-------------------------
# Node.js
#-------------------------
node_modules/
#-------------------------
# IDE / Development Files
#-------------------------
# Modules Testing
_modules/*
# phpenv local config
.php-version
# Jetbrains editors (PHPStorm, etc)
.idea/
*.iml
# NetBeans
/nbproject/
/build/
/nbbuild/
/dist/
/nbdist/
/nbactions.xml
/nb-configuration.xml
/.nb-gradle/
# Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
*.sublime-workspace
*.sublime-project
.phpintel
/api/
# Visual Studio Code
.vscode/
/results/
/phpunit*.xml

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2014-2019 British Columbia Institute of Technology
Copyright (c) 2019-present CodeIgniter Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# CodeIgniter 4 Application Starter
## What is CodeIgniter?
CodeIgniter is a PHP full-stack web framework that is light, fast, flexible and secure.
More information can be found at the [official site](https://codeigniter.com).
This repository holds a composer-installable app starter.
It has been built from the
[development repository](https://github.com/codeigniter4/CodeIgniter4).
More information about the plans for version 4 can be found in [CodeIgniter 4](https://forum.codeigniter.com/forumdisplay.php?fid=28) on the forums.
You can read the [user guide](https://codeigniter.com/user_guide/)
corresponding to the latest version of the framework.
## Installation & updates
`composer create-project codeigniter4/appstarter` then `composer update` whenever
there is a new release of the framework.
When updating, check the release notes to see if there are any changes you might need to apply
to your `app` folder. The affected files can be copied or merged from
`vendor/codeigniter4/framework/app`.
## Setup
Copy `env` to `.env` and tailor for your app, specifically the baseURL
and any database settings.
## Important Change with index.php
`index.php` is no longer in the root of the project! It has been moved inside the *public* folder,
for better security and separation of components.
This means that you should configure your web server to "point" to your project's *public* folder, and
not to the project root. A better practice would be to configure a virtual host to point there. A poor practice would be to point your web server to the project root and expect to enter *public/...*, as the rest of your logic and the
framework are exposed.
**Please** read the user guide for a better explanation of how CI4 works!
## Repository Management
We use GitHub issues, in our main repository, to track **BUGS** and to track approved **DEVELOPMENT** work packages.
We use our [forum](http://forum.codeigniter.com) to provide SUPPORT and to discuss
FEATURE REQUESTS.
This repository is a "distribution" one, built by our release preparation script.
Problems with it can be raised on our forum, or as issues in the main repository.
## Server Requirements
PHP version 8.1 or higher is required, with the following extensions installed:
- [intl](http://php.net/manual/en/intl.requirements.php)
- [mbstring](http://php.net/manual/en/mbstring.installation.php)
> [!WARNING]
> - The end of life date for PHP 7.4 was November 28, 2022.
> - The end of life date for PHP 8.0 was November 26, 2023.
> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately.
> - The end of life date for PHP 8.1 will be December 31, 2025.
Additionally, make sure that the following extensions are enabled in your PHP:
- json (enabled by default - don't turn it off)
- [mysqlnd](http://php.net/manual/en/mysqlnd.install.php) if you plan to use MySQL
- [libcurl](http://php.net/manual/en/curl.requirements.php) if you plan to use the HTTP\CURLRequest library

6
app/.htaccess Normal file
View File

@@ -0,0 +1,6 @@
<IfModule authz_core_module>
Require all denied
</IfModule>
<IfModule !authz_core_module>
Deny from all
</IfModule>

15
app/Common.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
/**
* The goal of this file is to allow developers a location
* where they can overwrite core procedural functions and
* replace them with their own. This file is loaded during
* the bootstrap process and is called during the framework's
* execution.
*
* This can be looked at as a `master helper` file that is
* loaded early on, and may also contain additional functions
* that you'd like to use throughout your entire application
*
* @see: https://codeigniter.com/user_guide/extending/common.html
*/

202
app/Config/App.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class App extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Base Site URL
* --------------------------------------------------------------------------
*
* URL to your CodeIgniter root. Typically, this will be your base URL,
* WITH a trailing slash:
*
* E.g., http://example.com/
*/
public string $baseURL = 'http://bapenda.garutkab.test/';
/**
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
* If you want to accept multiple Hostnames, set this.
*
* E.g.,
* When your site URL ($baseURL) is 'http://example.com/', and your site
* also accepts 'http://media.example.com/' and 'http://accounts.example.com/':
* ['media.example.com', 'accounts.example.com']
*
* @var list<string>
*/
public array $allowedHostnames = [];
/**
* --------------------------------------------------------------------------
* Index File
* --------------------------------------------------------------------------
*
* Typically, this will be your `index.php` file, unless you've renamed it to
* something else. If you have configured your web server to remove this file
* from your site URIs, set this variable to an empty string.
*/
public string $indexPage = '';
/**
* --------------------------------------------------------------------------
* URI PROTOCOL
* --------------------------------------------------------------------------
*
* This item determines which server global should be used to retrieve the
* URI string. The default setting of 'REQUEST_URI' works for most servers.
* If your links do not seem to work, try one of the other delicious flavors:
*
* 'REQUEST_URI': Uses $_SERVER['REQUEST_URI']
* 'QUERY_STRING': Uses $_SERVER['QUERY_STRING']
* 'PATH_INFO': Uses $_SERVER['PATH_INFO']
*
* WARNING: If you set this to 'PATH_INFO', URIs will always be URL-decoded!
*/
public string $uriProtocol = 'REQUEST_URI';
/*
|--------------------------------------------------------------------------
| Allowed URL Characters
|--------------------------------------------------------------------------
|
| This lets you specify which characters are permitted within your URLs.
| When someone tries to submit a URL with disallowed characters they will
| get a warning message.
|
| As a security measure you are STRONGLY encouraged to restrict URLs to
| as few characters as possible.
|
| By default, only these are allowed: `a-z 0-9~%.:_-`
|
| Set an empty string to allow all characters -- but only if you are insane.
|
| The configured value is actually a regular expression character group
| and it will be used as: '/\A[<permittedURIChars>]+\z/iu'
|
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
*/
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
/**
* --------------------------------------------------------------------------
* Default Locale
* --------------------------------------------------------------------------
*
* The Locale roughly represents the language and location that your visitor
* is viewing the site from. It affects the language strings and other
* strings (like currency markers, numbers, etc), that your program
* should run under for this request.
*/
public string $defaultLocale = 'id';
/**
* --------------------------------------------------------------------------
* Negotiate Locale
* --------------------------------------------------------------------------
*
* If true, the current Request object will automatically determine the
* language to use based on the value of the Accept-Language header.
*
* If false, no automatic detection will be performed.
*/
public bool $negotiateLocale = false;
/**
* --------------------------------------------------------------------------
* Supported Locales
* --------------------------------------------------------------------------
*
* If $negotiateLocale is true, this array lists the locales supported
* by the application in descending order of priority. If no match is
* found, the first locale will be used.
*
* IncomingRequest::setLocale() also uses this list.
*
* @var list<string>
*/
public array $supportedLocales = ['id', 'en'];
/**
* --------------------------------------------------------------------------
* Application Timezone
* --------------------------------------------------------------------------
*
* The default timezone that will be used in your application to display
* dates with the date helper, and can be retrieved through app_timezone()
*
* @see https://www.php.net/manual/en/timezones.php for list of timezones
* supported by PHP.
*/
public string $appTimezone = 'Asia/Jakarta';
/**
* --------------------------------------------------------------------------
* Default Character Set
* --------------------------------------------------------------------------
*
* This determines which character set is used by default in various methods
* that require a character set to be provided.
*
* @see http://php.net/htmlspecialchars for a list of supported charsets.
*/
public string $charset = 'UTF-8';
/**
* --------------------------------------------------------------------------
* Force Global Secure Requests
* --------------------------------------------------------------------------
*
* If true, this will force every request made to this application to be
* made via a secure connection (HTTPS). If the incoming request is not
* secure, the user will be redirected to a secure version of the page
* and the HTTP Strict Transport Security (HSTS) header will be set.
*/
public bool $forceGlobalSecureRequests = false;
/**
* --------------------------------------------------------------------------
* Reverse Proxy IPs
* --------------------------------------------------------------------------
*
* If your server is behind a reverse proxy, you must whitelist the proxy
* IP addresses from which CodeIgniter should trust headers such as
* X-Forwarded-For or Client-IP in order to properly identify
* the visitor's IP address.
*
* You need to set a proxy IP address or IP address with subnets and
* the HTTP header for the client IP address.
*
* Here are some examples:
* [
* '10.0.1.200' => 'X-Forwarded-For',
* '192.168.5.0/24' => 'X-Real-IP',
* ]
*
* @var array<string, string>
*/
public array $proxyIPs = [];
/**
* --------------------------------------------------------------------------
* Content Security Policy
* --------------------------------------------------------------------------
*
* Enables the Response's Content Secure Policy to restrict the sources that
* can be used for images, scripts, CSS files, audio, video, etc. If enabled,
* the Response object will populate default values for the policy from the
* `ContentSecurityPolicy.php` file. Controllers can always add to those
* restrictions at run time.
*
* For a better understanding of CSP, see these documents:
*
* @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/
* @see http://www.w3.org/TR/CSP/
*/
public bool $CSPEnabled = false;
}

92
app/Config/Autoload.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
namespace Config;
use CodeIgniter\Config\AutoloadConfig;
/**
* -------------------------------------------------------------------
* AUTOLOADER CONFIGURATION
* -------------------------------------------------------------------
*
* This file defines the namespaces and class maps so the Autoloader
* can find the files as needed.
*
* NOTE: If you use an identical key in $psr4 or $classmap, then
* the values in this file will overwrite the framework's values.
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*/
class Autoload extends AutoloadConfig
{
/**
* -------------------------------------------------------------------
* Namespaces
* -------------------------------------------------------------------
* This maps the locations of any namespaces in your application to
* their location on the file system. These are used by the autoloader
* to locate files the first time they have been instantiated.
*
* The 'Config' (APPPATH . 'Config') and 'CodeIgniter' (SYSTEMPATH) are
* already mapped for you.
*
* You may change the name of the 'App' namespace if you wish,
* but this should be done prior to creating any namespaced classes,
* else you will need to modify all of those classes for this to work.
*
* @var array<string, list<string>|string>
*/
public $psr4 = [
APP_NAMESPACE => APPPATH,
];
/**
* -------------------------------------------------------------------
* Class Map
* -------------------------------------------------------------------
* The class map provides a map of class names and their exact
* location on the drive. Classes loaded in this manner will have
* slightly faster performance because they will not have to be
* searched for within one or more directories as they would if they
* were being autoloaded through a namespace.
*
* Prototype:
* $classmap = [
* 'MyClass' => '/path/to/class/file.php'
* ];
*
* @var array<string, string>
*/
public $classmap = [];
/**
* -------------------------------------------------------------------
* Files
* -------------------------------------------------------------------
* The files array provides a list of paths to __non-class__ files
* that will be autoloaded. This can be useful for bootstrap operations
* or for loading functions.
*
* Prototype:
* $files = [
* '/path/to/my/file.php',
* ];
*
* @var list<string>
*/
public $files = [];
/**
* -------------------------------------------------------------------
* Helpers
* -------------------------------------------------------------------
* Prototype:
* $helpers = [
* 'form',
* ];
*
* @var list<string>
*/
public $helpers = [];
}

View File

@@ -0,0 +1,34 @@
<?php
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| In development, we want to show as many errors as possible to help
| make sure they don't make it to production. And save us hours of
| painful debugging.
|
| If you set 'display_errors' to '1', CI4's detailed error report will show.
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
/*
|--------------------------------------------------------------------------
| DEBUG BACKTRACES
|--------------------------------------------------------------------------
| If true, this constant will tell the error screens to display debug
| backtraces along with the other error information. If you would
| prefer to not see this, set this value to false.
*/
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. This will control whether Kint is loaded, and a few other
| items. It can always be used within your own application too.
*/
defined('CI_DEBUG') || define('CI_DEBUG', true);

View File

@@ -0,0 +1,25 @@
<?php
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| Don't show ANY in production environments. Instead, let the system catch
| it and display a generic error message.
|
| If you set 'display_errors' to '1', CI4's detailed error report will show.
*/
error_reporting(E_ALL & ~E_DEPRECATED);
// If you want to suppress more types of errors.
// error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED & ~E_STRICT & ~E_USER_NOTICE & ~E_USER_DEPRECATED);
ini_set('display_errors', '0');
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. It's not widely used currently, and may not survive
| release of the framework.
*/
defined('CI_DEBUG') || define('CI_DEBUG', false);

View File

@@ -0,0 +1,38 @@
<?php
/*
* The environment testing is reserved for PHPUnit testing. It has special
* conditions built into the framework at various places to assist with that.
* You cant use it for your development.
*/
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| In development, we want to show as many errors as possible to help
| make sure they don't make it to production. And save us hours of
| painful debugging.
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
/*
|--------------------------------------------------------------------------
| DEBUG BACKTRACES
|--------------------------------------------------------------------------
| If true, this constant will tell the error screens to display debug
| backtraces along with the other error information. If you would
| prefer to not see this, set this value to false.
*/
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. It's not widely used currently, and may not survive
| release of the framework.
*/
defined('CI_DEBUG') || define('CI_DEBUG', true);

View File

@@ -0,0 +1,20 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class CURLRequest extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* CURLRequest Share Options
* --------------------------------------------------------------------------
*
* Whether share options between requests or not.
*
* If true, all the options won't be reset between requests.
* It may cause an error request with unnecessary headers.
*/
public bool $shareOptions = false;
}

162
app/Config/Cache.php Normal file
View File

@@ -0,0 +1,162 @@
<?php
namespace Config;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\DummyHandler;
use CodeIgniter\Cache\Handlers\FileHandler;
use CodeIgniter\Cache\Handlers\MemcachedHandler;
use CodeIgniter\Cache\Handlers\PredisHandler;
use CodeIgniter\Cache\Handlers\RedisHandler;
use CodeIgniter\Cache\Handlers\WincacheHandler;
use CodeIgniter\Config\BaseConfig;
class Cache extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Primary Handler
* --------------------------------------------------------------------------
*
* The name of the preferred handler that should be used. If for some reason
* it is not available, the $backupHandler will be used in its place.
*/
public string $handler = 'file';
/**
* --------------------------------------------------------------------------
* Backup Handler
* --------------------------------------------------------------------------
*
* The name of the handler that will be used in case the first one is
* unreachable. Often, 'file' is used here since the filesystem is
* always available, though that's not always practical for the app.
*/
public string $backupHandler = 'dummy';
/**
* --------------------------------------------------------------------------
* Key Prefix
* --------------------------------------------------------------------------
*
* This string is added to all cache item names to help avoid collisions
* if you run multiple applications with the same cache engine.
*/
public string $prefix = '';
/**
* --------------------------------------------------------------------------
* Default TTL
* --------------------------------------------------------------------------
*
* The default number of seconds to save items when none is specified.
*
* WARNING: This is not used by framework handlers where 60 seconds is
* hard-coded, but may be useful to projects and modules. This will replace
* the hard-coded value in a future release.
*/
public int $ttl = 60;
/**
* --------------------------------------------------------------------------
* Reserved Characters
* --------------------------------------------------------------------------
*
* A string of reserved characters that will not be allowed in keys or tags.
* Strings that violate this restriction will cause handlers to throw.
* Default: {}()/\@:
*
* NOTE: The default set is required for PSR-6 compliance.
*/
public string $reservedCharacters = '{}()/\@:';
/**
* --------------------------------------------------------------------------
* File settings
* --------------------------------------------------------------------------
*
* Your file storage preferences can be specified below, if you are using
* the File driver.
*
* @var array{storePath?: string, mode?: int}
*/
public array $file = [
'storePath' => WRITEPATH . 'cache/',
'mode' => 0640,
];
/**
* -------------------------------------------------------------------------
* Memcached settings
* -------------------------------------------------------------------------
*
* Your Memcached servers can be specified below, if you are using
* the Memcached drivers.
*
* @see https://codeigniter.com/user_guide/libraries/caching.html#memcached
*
* @var array{host?: string, port?: int, weight?: int, raw?: bool}
*/
public array $memcached = [
'host' => '127.0.0.1',
'port' => 11211,
'weight' => 1,
'raw' => false,
];
/**
* -------------------------------------------------------------------------
* Redis settings
* -------------------------------------------------------------------------
*
* Your Redis server can be specified below, if you are using
* the Redis or Predis drivers.
*
* @var array{host?: string, password?: string|null, port?: int, timeout?: int, database?: int}
*/
public array $redis = [
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'database' => 0,
];
/**
* --------------------------------------------------------------------------
* Available Cache Handlers
* --------------------------------------------------------------------------
*
* This is an array of cache engine alias' and class names. Only engines
* that are listed here are allowed to be used.
*
* @var array<string, class-string<CacheInterface>>
*/
public array $validHandlers = [
'dummy' => DummyHandler::class,
'file' => FileHandler::class,
'memcached' => MemcachedHandler::class,
'predis' => PredisHandler::class,
'redis' => RedisHandler::class,
'wincache' => WincacheHandler::class,
];
/**
* --------------------------------------------------------------------------
* Web Page Caching: Cache Include Query String
* --------------------------------------------------------------------------
*
* Whether to take the URL query string into consideration when generating
* output cache files. Valid options are:
*
* false = Disabled
* true = Enabled, take all query parameters into account.
* Please be aware that this may result in numerous cache
* files generated for the same page over and over again.
* ['q'] = Enabled, but only take into account the specified list
* of query parameters.
*
* @var bool|list<string>
*/
public $cacheQueryString = false;
}

79
app/Config/Constants.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
/*
| --------------------------------------------------------------------
| App Namespace
| --------------------------------------------------------------------
|
| This defines the default Namespace that is used throughout
| CodeIgniter to refer to the Application directory. Change
| this constant to change the namespace that all application
| classes should use.
|
| NOTE: changing this will require manually modifying the
| existing namespaces of App\* namespaced-classes.
*/
defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
/*
| --------------------------------------------------------------------------
| Composer Path
| --------------------------------------------------------------------------
|
| The path that Composer's autoload file is expected to live. By default,
| the vendor folder is in the Root directory, but you can customize that here.
*/
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
/*
|--------------------------------------------------------------------------
| Timing Constants
|--------------------------------------------------------------------------
|
| Provide simple ways to work with the myriad of PHP functions that
| require information to be in seconds.
*/
defined('SECOND') || define('SECOND', 1);
defined('MINUTE') || define('MINUTE', 60);
defined('HOUR') || define('HOUR', 3600);
defined('DAY') || define('DAY', 86400);
defined('WEEK') || define('WEEK', 604800);
defined('MONTH') || define('MONTH', 2_592_000);
defined('YEAR') || define('YEAR', 31_536_000);
defined('DECADE') || define('DECADE', 315_360_000);
/*
| --------------------------------------------------------------------------
| Exit Status Codes
| --------------------------------------------------------------------------
|
| Used to indicate the conditions under which the script is exit()ing.
| While there is no universal standard for error codes, there are some
| broad conventions. Three such conventions are mentioned below, for
| those who wish to make use of them. The CodeIgniter defaults were
| chosen for the least overlap with these conventions, while still
| leaving room for others to be defined in future versions and user
| applications.
|
| The three main conventions used for determining exit status codes
| are as follows:
|
| Standard C/C++ Library (stdlibc):
| http://www.gnu.org/software/libc/manual/html_node/Exit-Status.html
| (This link also contains other GNU-specific conventions)
| BSD sysexits.h:
| http://www.gsp.com/cgi-bin/man.cgi?section=3&topic=sysexits
| Bash scripting:
| http://tldp.org/LDP/abs/html/exitcodes.html
|
*/
defined('EXIT_SUCCESS') || define('EXIT_SUCCESS', 0); // no errors
defined('EXIT_ERROR') || define('EXIT_ERROR', 1); // generic error
defined('EXIT_CONFIG') || define('EXIT_CONFIG', 3); // configuration error
defined('EXIT_UNKNOWN_FILE') || define('EXIT_UNKNOWN_FILE', 4); // file not found
defined('EXIT_UNKNOWN_CLASS') || define('EXIT_UNKNOWN_CLASS', 5); // unknown class
defined('EXIT_UNKNOWN_METHOD') || define('EXIT_UNKNOWN_METHOD', 6); // unknown class member
defined('EXIT_USER_INPUT') || define('EXIT_USER_INPUT', 7); // invalid user input
defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8); // database error
defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9); // lowest automatically-assigned error code
defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest automatically-assigned error code

View File

@@ -0,0 +1,176 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Stores the default settings for the ContentSecurityPolicy, if you
* choose to use it. The values here will be read in and set as defaults
* for the site. If needed, they can be overridden on a page-by-page basis.
*
* Suggested reference for explanations:
*
* @see https://www.html5rocks.com/en/tutorials/security/content-security-policy/
*/
class ContentSecurityPolicy extends BaseConfig
{
// -------------------------------------------------------------------------
// Broadbrush CSP management
// -------------------------------------------------------------------------
/**
* Default CSP report context
*/
public bool $reportOnly = false;
/**
* Specifies a URL where a browser will send reports
* when a content security policy is violated.
*/
public ?string $reportURI = null;
/**
* Instructs user agents to rewrite URL schemes, changing
* HTTP to HTTPS. This directive is for websites with
* large numbers of old URLs that need to be rewritten.
*/
public bool $upgradeInsecureRequests = false;
// -------------------------------------------------------------------------
// Sources allowed
// NOTE: once you set a policy to 'none', it cannot be further restricted
// -------------------------------------------------------------------------
/**
* Will default to self if not overridden
*
* @var list<string>|string|null
*/
public $defaultSrc;
/**
* Lists allowed scripts' URLs.
*
* @var list<string>|string
*/
public $scriptSrc = 'self';
/**
* Lists allowed stylesheets' URLs.
*
* @var list<string>|string
*/
public $styleSrc = 'self';
/**
* Defines the origins from which images can be loaded.
*
* @var list<string>|string
*/
public $imageSrc = 'self';
/**
* Restricts the URLs that can appear in a page's `<base>` element.
*
* Will default to self if not overridden
*
* @var list<string>|string|null
*/
public $baseURI;
/**
* Lists the URLs for workers and embedded frame contents
*
* @var list<string>|string
*/
public $childSrc = 'self';
/**
* Limits the origins that you can connect to (via XHR,
* WebSockets, and EventSource).
*
* @var list<string>|string
*/
public $connectSrc = 'self';
/**
* Specifies the origins that can serve web fonts.
*
* @var list<string>|string
*/
public $fontSrc;
/**
* Lists valid endpoints for submission from `<form>` tags.
*
* @var list<string>|string
*/
public $formAction = 'self';
/**
* Specifies the sources that can embed the current page.
* This directive applies to `<frame>`, `<iframe>`, `<embed>`,
* and `<applet>` tags. This directive can't be used in
* `<meta>` tags and applies only to non-HTML resources.
*
* @var list<string>|string|null
*/
public $frameAncestors;
/**
* The frame-src directive restricts the URLs which may
* be loaded into nested browsing contexts.
*
* @var list<string>|string|null
*/
public $frameSrc;
/**
* Restricts the origins allowed to deliver video and audio.
*
* @var list<string>|string|null
*/
public $mediaSrc;
/**
* Allows control over Flash and other plugins.
*
* @var list<string>|string
*/
public $objectSrc = 'self';
/**
* @var list<string>|string|null
*/
public $manifestSrc;
/**
* Limits the kinds of plugins a page may invoke.
*
* @var list<string>|string|null
*/
public $pluginTypes;
/**
* List of actions allowed.
*
* @var list<string>|string|null
*/
public $sandbox;
/**
* Nonce tag for style
*/
public string $styleNonceTag = '{csp-style-nonce}';
/**
* Nonce tag for script
*/
public string $scriptNonceTag = '{csp-script-nonce}';
/**
* Replace nonce tag automatically
*/
public bool $autoNonce = true;
}

108
app/Config/Cookie.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use DateTimeInterface;
class Cookie extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Cookie Prefix
* --------------------------------------------------------------------------
*
* Set a cookie name prefix if you need to avoid collisions.
*/
public string $prefix = '';
/**
* --------------------------------------------------------------------------
* Cookie Expires Timestamp
* --------------------------------------------------------------------------
*
* Default expires timestamp for cookies. Setting this to `0` will mean the
* cookie will not have the `Expires` attribute and will behave as a session
* cookie.
*
* @var DateTimeInterface|int|string
*/
public $expires = 0;
/**
* --------------------------------------------------------------------------
* Cookie Path
* --------------------------------------------------------------------------
*
* Typically will be a forward slash.
*/
public string $path = '/';
/**
* --------------------------------------------------------------------------
* Cookie Domain
* --------------------------------------------------------------------------
*
* Set to `.your-domain.com` for site-wide cookies.
*/
public string $domain = '';
/**
* --------------------------------------------------------------------------
* Cookie Secure
* --------------------------------------------------------------------------
*
* Cookie will only be set if a secure HTTPS connection exists.
* Only enabled in production environment.
*/
public bool $secure = (ENVIRONMENT === 'production');
/**
* --------------------------------------------------------------------------
* Cookie HTTPOnly
* --------------------------------------------------------------------------
*
* Cookie will only be accessible via HTTP(S) (no JavaScript).
*/
public bool $httponly = true;
/**
* --------------------------------------------------------------------------
* Cookie SameSite
* --------------------------------------------------------------------------
*
* Configure cookie SameSite setting. Allowed values are:
* - None
* - Lax
* - Strict
* - ''
*
* Alternatively, you can use the constant names:
* - `Cookie::SAMESITE_NONE`
* - `Cookie::SAMESITE_LAX`
* - `Cookie::SAMESITE_STRICT`
*
* Defaults to `Lax` for compatibility with modern browsers. Setting `''`
* (empty string) means default SameSite attribute set by browsers (`Lax`)
* will be set on cookies. If set to `None`, `$secure` must also be set.
*
* @var ''|'Lax'|'None'|'Strict'
*/
public string $samesite = 'Lax';
/**
* --------------------------------------------------------------------------
* Cookie Raw
* --------------------------------------------------------------------------
*
* This flag allows setting a "raw" cookie, i.e., its name and value are
* not URL encoded using `rawurlencode()`.
*
* If this is set to `true`, cookie names should be compliant of RFC 2616's
* list of allowed characters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
* @see https://tools.ietf.org/html/rfc2616#section-2.2
*/
public bool $raw = false;
}

105
app/Config/Cors.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Cross-Origin Resource Sharing (CORS) Configuration
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
*/
class Cors extends BaseConfig
{
/**
* The default CORS configuration.
*
* @var array{
* allowedOrigins: list<string>,
* allowedOriginsPatterns: list<string>,
* supportsCredentials: bool,
* allowedHeaders: list<string>,
* exposedHeaders: list<string>,
* allowedMethods: list<string>,
* maxAge: int,
* }
*/
public array $default = [
/**
* Origins for the `Access-Control-Allow-Origin` header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
*
* E.g.:
* - ['http://localhost:8080']
* - ['https://www.example.com']
*/
'allowedOrigins' => [],
/**
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
*
* NOTE: A pattern specified here is part of a regular expression. It will
* be actually `#\A<pattern>\z#`.
*
* E.g.:
* - ['https://\w+\.example\.com']
*/
'allowedOriginsPatterns' => [],
/**
* Weather to send the `Access-Control-Allow-Credentials` header.
*
* The Access-Control-Allow-Credentials response header tells browsers whether
* the server allows cross-origin HTTP requests to include credentials.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
*/
'supportsCredentials' => false,
/**
* Set headers to allow.
*
* The Access-Control-Allow-Headers response header is used in response to
* a preflight request which includes the Access-Control-Request-Headers to
* indicate which HTTP headers can be used during the actual request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
*/
'allowedHeaders' => [],
/**
* Set headers to expose.
*
* The Access-Control-Expose-Headers response header allows a server to
* indicate which response headers should be made available to scripts running
* in the browser, in response to a cross-origin request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
*/
'exposedHeaders' => [],
/**
* Set methods to allow.
*
* The Access-Control-Allow-Methods response header specifies one or more
* methods allowed when accessing a resource in response to a preflight
* request.
*
* E.g.:
* - ['GET', 'POST', 'PUT', 'DELETE']
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
*/
'allowedMethods' => [],
/**
* Set how many seconds the results of a preflight request can be cached.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
*/
'maxAge' => 7200,
];
}

204
app/Config/Database.php Normal file
View File

@@ -0,0 +1,204 @@
<?php
namespace Config;
use CodeIgniter\Database\Config;
/**
* Database Configuration
*/
class Database extends Config
{
/**
* The directory that holds the Migrations and Seeds directories.
*/
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
/**
* Lets you choose which connection group to use if no other is specified.
*/
public string $defaultGroup = 'default';
/**
* The default database connection.
*
* @var array<string, mixed>
*/
public array $default = [
'DSN' => '',
'hostname' => 'localhost',
'username' => '',
'password' => '',
'database' => '',
'DBDriver' => 'MySQLi',
'DBPrefix' => '',
'pConnect' => false,
'DBDebug' => true,
'charset' => 'utf8mb4',
'DBCollat' => 'utf8mb4_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => 3306,
'numberNative' => false,
'foundRows' => false,
'dateFormat' => [
'date' => 'Y-m-d',
'datetime' => 'Y-m-d H:i:s',
'time' => 'H:i:s',
],
];
// /**
// * Sample database connection for SQLite3.
// *
// * @var array<string, mixed>
// */
// public array $default = [
// 'database' => 'database.db',
// 'DBDriver' => 'SQLite3',
// 'DBPrefix' => '',
// 'DBDebug' => true,
// 'swapPre' => '',
// 'failover' => [],
// 'foreignKeys' => true,
// 'busyTimeout' => 1000,
// 'synchronous' => null,
// 'dateFormat' => [
// 'date' => 'Y-m-d',
// 'datetime' => 'Y-m-d H:i:s',
// 'time' => 'H:i:s',
// ],
// ];
// /**
// * Sample database connection for Postgre.
// *
// * @var array<string, mixed>
// */
// public array $default = [
// 'DSN' => '',
// 'hostname' => 'localhost',
// 'username' => 'root',
// 'password' => 'root',
// 'database' => 'ci4',
// 'schema' => 'public',
// 'DBDriver' => 'Postgre',
// 'DBPrefix' => '',
// 'pConnect' => false,
// 'DBDebug' => true,
// 'charset' => 'utf8',
// 'swapPre' => '',
// 'failover' => [],
// 'port' => 5432,
// 'dateFormat' => [
// 'date' => 'Y-m-d',
// 'datetime' => 'Y-m-d H:i:s',
// 'time' => 'H:i:s',
// ],
// ];
// /**
// * Sample database connection for SQLSRV.
// *
// * @var array<string, mixed>
// */
// public array $default = [
// 'DSN' => '',
// 'hostname' => 'localhost',
// 'username' => 'root',
// 'password' => 'root',
// 'database' => 'ci4',
// 'schema' => 'dbo',
// 'DBDriver' => 'SQLSRV',
// 'DBPrefix' => '',
// 'pConnect' => false,
// 'DBDebug' => true,
// 'charset' => 'utf8',
// 'swapPre' => '',
// 'encrypt' => false,
// 'failover' => [],
// 'port' => 1433,
// 'dateFormat' => [
// 'date' => 'Y-m-d',
// 'datetime' => 'Y-m-d H:i:s',
// 'time' => 'H:i:s',
// ],
// ];
// /**
// * Sample database connection for OCI8.
// *
// * You may need the following environment variables:
// * NLS_LANG = 'AMERICAN_AMERICA.UTF8'
// * NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
// * NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
// * NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
// *
// * @var array<string, mixed>
// */
// public array $default = [
// 'DSN' => 'localhost:1521/XEPDB1',
// 'username' => 'root',
// 'password' => 'root',
// 'DBDriver' => 'OCI8',
// 'DBPrefix' => '',
// 'pConnect' => false,
// 'DBDebug' => true,
// 'charset' => 'AL32UTF8',
// 'swapPre' => '',
// 'failover' => [],
// 'dateFormat' => [
// 'date' => 'Y-m-d',
// 'datetime' => 'Y-m-d H:i:s',
// 'time' => 'H:i:s',
// ],
// ];
/**
* This database connection is used when running PHPUnit database tests.
*
* @var array<string, mixed>
*/
public array $tests = [
'DSN' => '',
'hostname' => '127.0.0.1',
'username' => '',
'password' => '',
'database' => ':memory:',
'DBDriver' => 'SQLite3',
'DBPrefix' => 'db_', // Needed to ensure we're working correctly with prefixes live. DO NOT REMOVE FOR CI DEVS
'pConnect' => false,
'DBDebug' => true,
'charset' => 'utf8',
'DBCollat' => '',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => 3306,
'foreignKeys' => true,
'busyTimeout' => 1000,
'synchronous' => null,
'dateFormat' => [
'date' => 'Y-m-d',
'datetime' => 'Y-m-d H:i:s',
'time' => 'H:i:s',
],
];
public function __construct()
{
parent::__construct();
// Ensure that we always set the database group to 'tests' if
// we are currently running an automated test suite, so that
// we don't overwrite live data on accident.
if (ENVIRONMENT === 'testing') {
$this->defaultGroup = 'tests';
}
}
}

43
app/Config/DocTypes.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace Config;
class DocTypes
{
/**
* List of valid document types.
*
* @var array<string, string>
*/
public array $list = [
'xhtml11' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
'xhtml1-strict' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
'xhtml1-trans' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
'xhtml1-frame' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">',
'xhtml-basic11' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">',
'html5' => '<!DOCTYPE html>',
'html4-strict' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',
'html4-trans' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">',
'html4-frame' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">',
'mathml1' => '<!DOCTYPE math SYSTEM "http://www.w3.org/Math/DTD/mathml1/mathml.dtd">',
'mathml2' => '<!DOCTYPE math PUBLIC "-//W3C//DTD MathML 2.0//EN" "http://www.w3.org/Math/DTD/mathml2/mathml2.dtd">',
'svg10' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">',
'svg11' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
'svg11-basic' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Basic//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd">',
'svg11-tiny' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Tiny//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd">',
'xhtml-math-svg-xh' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN" "http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd">',
'xhtml-math-svg-sh' => '<!DOCTYPE svg:svg PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN" "http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd">',
'xhtml-rdfa-1' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.0//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-1.dtd">',
'xhtml-rdfa-2' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.1//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-2.dtd">',
];
/**
* Whether to remove the solidus (`/`) character for void HTML elements (e.g. `<input>`)
* for HTML5 compatibility.
*
* Set to:
* `true` - to be HTML5 compatible
* `false` - to be XHTML compatible
*/
public bool $html5 = true;
}

121
app/Config/Email.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Email extends BaseConfig
{
public string $fromEmail = '';
public string $fromName = '';
public string $recipients = '';
/**
* The "user agent"
*/
public string $userAgent = 'CodeIgniter';
/**
* The mail sending protocol: mail, sendmail, smtp
*/
public string $protocol = 'mail';
/**
* The server path to Sendmail.
*/
public string $mailPath = '/usr/sbin/sendmail';
/**
* SMTP Server Hostname
*/
public string $SMTPHost = '';
/**
* SMTP Username
*/
public string $SMTPUser = '';
/**
* SMTP Password
*/
public string $SMTPPass = '';
/**
* SMTP Port
*/
public int $SMTPPort = 25;
/**
* SMTP Timeout (in seconds)
*/
public int $SMTPTimeout = 5;
/**
* Enable persistent SMTP connections
*/
public bool $SMTPKeepAlive = false;
/**
* SMTP Encryption.
*
* @var string '', 'tls' or 'ssl'. 'tls' will issue a STARTTLS command
* to the server. 'ssl' means implicit SSL. Connection on port
* 465 should set this to ''.
*/
public string $SMTPCrypto = 'tls';
/**
* Enable word-wrap
*/
public bool $wordWrap = true;
/**
* Character count to wrap at
*/
public int $wrapChars = 76;
/**
* Type of mail, either 'text' or 'html'
*/
public string $mailType = 'text';
/**
* Character set (utf-8, iso-8859-1, etc.)
*/
public string $charset = 'UTF-8';
/**
* Whether to validate the email address
*/
public bool $validate = false;
/**
* Email Priority. 1 = highest. 5 = lowest. 3 = normal
*/
public int $priority = 3;
/**
* Newline character. (Use “\r\n” to comply with RFC 822)
*/
public string $CRLF = "\r\n";
/**
* Newline character. (Use “\r\n” to comply with RFC 822)
*/
public string $newline = "\r\n";
/**
* Enable BCC Batch Mode.
*/
public bool $BCCBatchMode = false;
/**
* Number of emails in each BCC batch
*/
public int $BCCBatchSize = 200;
/**
* Enable notify message from server
*/
public bool $DSN = false;
}

92
app/Config/Encryption.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Encryption configuration.
*
* These are the settings used for encryption, if you don't pass a parameter
* array to the encrypter for creation/initialization.
*/
class Encryption extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Encryption Key Starter
* --------------------------------------------------------------------------
*
* If you use the Encryption class you must set an encryption key (seed).
* You need to ensure it is long enough for the cipher and mode you plan to use.
* See the user guide for more info.
*/
public string $key = '';
/**
* --------------------------------------------------------------------------
* Encryption Driver to Use
* --------------------------------------------------------------------------
*
* One of the supported encryption drivers.
*
* Available drivers:
* - OpenSSL
* - Sodium
*/
public string $driver = 'OpenSSL';
/**
* --------------------------------------------------------------------------
* SodiumHandler's Padding Length in Bytes
* --------------------------------------------------------------------------
*
* This is the number of bytes that will be padded to the plaintext message
* before it is encrypted. This value should be greater than zero.
*
* See the user guide for more information on padding.
*/
public int $blockSize = 16;
/**
* --------------------------------------------------------------------------
* Encryption digest
* --------------------------------------------------------------------------
*
* HMAC digest to use, e.g. 'SHA512' or 'SHA256'. Default value is 'SHA512'.
*/
public string $digest = 'SHA512';
/**
* Whether the cipher-text should be raw. If set to false, then it will be base64 encoded.
* This setting is only used by OpenSSLHandler.
*
* Set to false for CI3 Encryption compatibility.
*/
public bool $rawData = true;
/**
* Encryption key info.
* This setting is only used by OpenSSLHandler.
*
* Set to 'encryption' for CI3 Encryption compatibility.
*/
public string $encryptKeyInfo = '';
/**
* Authentication key info.
* This setting is only used by OpenSSLHandler.
*
* Set to 'authentication' for CI3 Encryption compatibility.
*/
public string $authKeyInfo = '';
/**
* Cipher to use.
* This setting is only used by OpenSSLHandler.
*
* Set to 'AES-128-CBC' to decrypt encrypted data that encrypted
* by CI3 Encryption default configuration.
*/
public string $cipher = 'AES-256-CTR';
}

55
app/Config/Events.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
namespace Config;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\FrameworkException;
use CodeIgniter\HotReloader\HotReloader;
/*
* --------------------------------------------------------------------
* Application Events
* --------------------------------------------------------------------
* Events allow you to tap into the execution of the program without
* modifying or extending core files. This file provides a central
* location to define your events, though they can always be added
* at run-time, also, if needed.
*
* You create code that can execute by subscribing to events with
* the 'on()' method. This accepts any form of callable, including
* Closures, that will be executed when the event is triggered.
*
* Example:
* Events::on('create', [$myInstance, 'myMethod']);
*/
Events::on('pre_system', static function (): void {
if (ENVIRONMENT !== 'testing') {
if (ini_get('zlib.output_compression')) {
throw FrameworkException::forEnabledZlibOutputCompression();
}
while (ob_get_level() > 0) {
ob_end_flush();
}
ob_start(static fn ($buffer) => $buffer);
}
/*
* --------------------------------------------------------------------
* Debug Toolbar Listeners.
* --------------------------------------------------------------------
* If you delete, they will no longer be collected.
*/
if (CI_DEBUG && ! is_cli()) {
Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect');
service('toolbar')->respond();
// Hot Reload route - for framework use on the hot reloader.
if (ENVIRONMENT === 'development') {
service('routes')->get('__hot-reload', static function (): void {
(new HotReloader())->run();
});
}
}
});

106
app/Config/Exceptions.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Debug\ExceptionHandler;
use CodeIgniter\Debug\ExceptionHandlerInterface;
use Psr\Log\LogLevel;
use Throwable;
/**
* Setup how the exception handler works.
*/
class Exceptions extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* LOG EXCEPTIONS?
* --------------------------------------------------------------------------
* If true, then exceptions will be logged
* through Services::Log.
*
* Default: true
*/
public bool $log = true;
/**
* --------------------------------------------------------------------------
* DO NOT LOG STATUS CODES
* --------------------------------------------------------------------------
* Any status codes here will NOT be logged if logging is turned on.
* By default, only 404 (Page Not Found) exceptions are ignored.
*
* @var list<int>
*/
public array $ignoreCodes = [404];
/**
* --------------------------------------------------------------------------
* Error Views Path
* --------------------------------------------------------------------------
* This is the path to the directory that contains the 'cli' and 'html'
* directories that hold the views used to generate errors.
*
* Default: APPPATH.'Views/errors'
*/
public string $errorViewPath = APPPATH . 'Views/errors';
/**
* --------------------------------------------------------------------------
* HIDE FROM DEBUG TRACE
* --------------------------------------------------------------------------
* Any data that you would like to hide from the debug trace.
* In order to specify 2 levels, use "/" to separate.
* ex. ['server', 'setup/password', 'secret_token']
*
* @var list<string>
*/
public array $sensitiveDataInTrace = [];
/**
* --------------------------------------------------------------------------
* WHETHER TO THROW AN EXCEPTION ON DEPRECATED ERRORS
* --------------------------------------------------------------------------
* If set to `true`, DEPRECATED errors are only logged and no exceptions are
* thrown. This option also works for user deprecations.
*/
public bool $logDeprecations = true;
/**
* --------------------------------------------------------------------------
* LOG LEVEL THRESHOLD FOR DEPRECATIONS
* --------------------------------------------------------------------------
* If `$logDeprecations` is set to `true`, this sets the log level
* to which the deprecation will be logged. This should be one of the log
* levels recognized by PSR-3.
*
* The related `Config\Logger::$threshold` should be adjusted, if needed,
* to capture logging the deprecations.
*/
public string $deprecationLogLevel = LogLevel::WARNING;
/*
* DEFINE THE HANDLERS USED
* --------------------------------------------------------------------------
* Given the HTTP status code, returns exception handler that
* should be used to deal with this error. By default, it will run CodeIgniter's
* default handler and display the error information in the expected format
* for CLI, HTTP, or AJAX requests, as determined by is_cli() and the expected
* response format.
*
* Custom handlers can be returned if you want to handle one or more specific
* error codes yourself like:
*
* if (in_array($statusCode, [400, 404, 500])) {
* return new \App\Libraries\MyExceptionHandler();
* }
* if ($exception instanceOf PageNotFoundException) {
* return new \App\Libraries\MyExceptionHandler();
* }
*/
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
{
return new ExceptionHandler($this);
}
}

37
app/Config/Feature.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Enable/disable backward compatibility breaking features.
*/
class Feature extends BaseConfig
{
/**
* Use improved new auto routing instead of the legacy version.
*/
public bool $autoRoutesImproved = true;
/**
* Use filter execution order in 4.4 or before.
*/
public bool $oldFilterOrder = false;
/**
* The behavior of `limit(0)` in Query Builder.
*
* If true, `limit(0)` returns all records. (the behavior of 4.4.x or before in version 4.x.)
* If false, `limit(0)` returns no records. (the behavior of 3.1.9 or later in version 3.x.)
*/
public bool $limitZeroAsAll = true;
/**
* Use strict location negotiation.
*
* By default, the locale is selected based on a loose comparison of the language code (ISO 639-1)
* Enabling strict comparison will also consider the region code (ISO 3166-1 alpha-2).
*/
public bool $strictLocaleNegotiation = false;
}

123
app/Config/Filters.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
namespace Config;
use CodeIgniter\Config\Filters as BaseFilters;
use CodeIgniter\Filters\Cors;
use CodeIgniter\Filters\CSRF;
use CodeIgniter\Filters\DebugToolbar;
use CodeIgniter\Filters\ForceHTTPS;
use CodeIgniter\Filters\Honeypot;
use CodeIgniter\Filters\InvalidChars;
use CodeIgniter\Filters\PageCache;
use CodeIgniter\Filters\PerformanceMetrics;
use CodeIgniter\Filters\SecureHeaders;
use App\Filters\AuthFilter;
use App\Filters\SecurityHeaders;
use App\Filters\ThrottleFilter;
class Filters extends BaseFilters
{
/**
* Configures aliases for Filter classes to
* make reading things nicer and simpler.
*
* @var array<string, class-string|list<class-string>>
*
* [filter_name => classname]
* or [filter_name => [classname1, classname2, ...]]
*/
public array $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'cors' => Cors::class,
'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class,
'auth' => AuthFilter::class,
'securityheaders' => SecurityHeaders::class,
'throttle' => ThrottleFilter::class,
];
/**
* List of special required filters.
*
* The filters listed here are special. They are applied before and after
* other kinds of filters, and always applied even if a route does not exist.
*
* Filters set by default provide framework functionality. If removed,
* those functions will no longer work.
*
* @see https://codeigniter.com/user_guide/incoming/filters.html#provided-filters
*
* @var array{before: list<string>, after: list<string>}
*/
public array $required = [
'before' => [
'forcehttps', // Force Global Secure Requests
'pagecache', // Web Page Caching
],
'after' => [
'pagecache', // Web Page Caching
'performance', // Performance Metrics
'toolbar', // Debug Toolbar
],
];
/**
* List of filter aliases that are always
* applied before and after every request.
*
* @var array{
* before: array<string, array{except: list<string>|string}>|list<string>,
* after: array<string, array{except: list<string>|string}>|list<string>
* }
*/
public array $globals = [
'before' => [
// 'honeypot',
'csrf' => [
'except' => [
'api/*', // Exclude API routes if any
],
],
// 'invalidchars',
],
'after' => [
// 'honeypot',
'securityheaders',
],
];
/**
* List of filter aliases that works on a
* particular HTTP method (GET, POST, etc.).
*
* Example:
* 'POST' => ['foo', 'bar']
*
* If you use this, you should disable auto-routing because auto-routing
* permits any HTTP method to access a controller. Accessing the controller
* with a method you don't expect could bypass the filter.
*
* @var array<string, list<string>>
*/
public array $methods = [];
/**
* List of filter aliases that should run on any
* before or after URI patterns.
*
* Example:
* 'isLoggedIn' => ['before' => ['account/*', 'profiles/*']]
*
* @var array<string, array<string, list<string>>>
*/
public array $filters = [
// NOTE: Throttle filter DISABLED untuk auth/login
// Rate limiting di-handle di AuthController berdasarkan failed attempts saja
];
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Config;
use CodeIgniter\Config\ForeignCharacters as BaseForeignCharacters;
/**
* @immutable
*/
class ForeignCharacters extends BaseForeignCharacters
{
}

64
app/Config/Format.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Format\JSONFormatter;
use CodeIgniter\Format\XMLFormatter;
class Format extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Available Response Formats
* --------------------------------------------------------------------------
*
* When you perform content negotiation with the request, these are the
* available formats that your application supports. This is currently
* only used with the API\ResponseTrait. A valid Formatter must exist
* for the specified format.
*
* These formats are only checked when the data passed to the respond()
* method is an array.
*
* @var list<string>
*/
public array $supportedResponseFormats = [
'application/json',
'application/xml', // machine-readable XML
'text/xml', // human-readable XML
];
/**
* --------------------------------------------------------------------------
* Formatters
* --------------------------------------------------------------------------
*
* Lists the class to use to format responses with of a particular type.
* For each mime type, list the class that should be used. Formatters
* can be retrieved through the getFormatter() method.
*
* @var array<string, string>
*/
public array $formatters = [
'application/json' => JSONFormatter::class,
'application/xml' => XMLFormatter::class,
'text/xml' => XMLFormatter::class,
];
/**
* --------------------------------------------------------------------------
* Formatters Options
* --------------------------------------------------------------------------
*
* Additional Options to adjust default formatters behaviour.
* For each mime type, list the additional options that should be used.
*
* @var array<string, int>
*/
public array $formatterOptions = [
'application/json' => JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
'application/xml' => 0,
'text/xml' => 0,
];
}

44
app/Config/Generators.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Generators extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Generator Commands' Views
* --------------------------------------------------------------------------
*
* This array defines the mapping of generator commands to the view files
* they are using. If you need to customize them for your own, copy these
* view files in your own folder and indicate the location here.
*
* You will notice that the views have special placeholders enclosed in
* curly braces `{...}`. These placeholders are used internally by the
* generator commands in processing replacements, thus you are warned
* not to delete them or modify the names. If you will do so, you may
* end up disrupting the scaffolding process and throw errors.
*
* YOU HAVE BEEN WARNED!
*
* @var array<string, array<string, string>|string>
*/
public array $views = [
'make:cell' => [
'class' => 'CodeIgniter\Commands\Generators\Views\cell.tpl.php',
'view' => 'CodeIgniter\Commands\Generators\Views\cell_view.tpl.php',
],
'make:command' => 'CodeIgniter\Commands\Generators\Views\command.tpl.php',
'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php',
'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php',
'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php',
'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php',
'make:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php',
'make:model' => 'CodeIgniter\Commands\Generators\Views\model.tpl.php',
'make:seeder' => 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php',
'make:validation' => 'CodeIgniter\Commands\Generators\Views\validation.tpl.php',
'session:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php',
];
}

42
app/Config/Honeypot.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Honeypot extends BaseConfig
{
/**
* Makes Honeypot visible or not to human
*/
public bool $hidden = true;
/**
* Honeypot Label Content
*/
public string $label = 'Fill This Field';
/**
* Honeypot Field Name
*/
public string $name = 'honeypot';
/**
* Honeypot HTML Template
*/
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';
/**
* Honeypot container
*
* If you enabled CSP, you can remove `style="display:none"`.
*/
public string $container = '<div style="display:none">{template}</div>';
/**
* The id attribute for Honeypot container tag
*
* Used when CSP is enabled.
*/
public string $containerId = 'hpc';
}

31
app/Config/Images.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Images\Handlers\GDHandler;
use CodeIgniter\Images\Handlers\ImageMagickHandler;
class Images extends BaseConfig
{
/**
* Default handler used if no other handler is specified.
*/
public string $defaultHandler = 'gd';
/**
* The path to the image library.
* Required for ImageMagick, GraphicsMagick, or NetPBM.
*/
public string $libraryPath = '/usr/local/bin/convert';
/**
* The available handler classes.
*
* @var array<string, string>
*/
public array $handlers = [
'gd' => GDHandler::class,
'imagick' => ImageMagickHandler::class,
];
}

63
app/Config/Kint.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
namespace Config;
use Kint\Parser\ConstructablePluginInterface;
use Kint\Renderer\Rich\TabPluginInterface;
use Kint\Renderer\Rich\ValuePluginInterface;
/**
* --------------------------------------------------------------------------
* Kint
* --------------------------------------------------------------------------
*
* We use Kint's `RichRenderer` and `CLIRenderer`. This area contains options
* that you can set to customize how Kint works for you.
*
* @see https://kint-php.github.io/kint/ for details on these settings.
*/
class Kint
{
/*
|--------------------------------------------------------------------------
| Global Settings
|--------------------------------------------------------------------------
*/
/**
* @var list<class-string<ConstructablePluginInterface>|ConstructablePluginInterface>|null
*/
public $plugins;
public int $maxDepth = 6;
public bool $displayCalledFrom = true;
public bool $expanded = false;
/*
|--------------------------------------------------------------------------
| RichRenderer Settings
|--------------------------------------------------------------------------
*/
public string $richTheme = 'aante-light.css';
public bool $richFolder = false;
/**
* @var array<string, class-string<ValuePluginInterface>>|null
*/
public $richObjectPlugins;
/**
* @var array<string, class-string<TabPluginInterface>>|null
*/
public $richTabPlugins;
/*
|--------------------------------------------------------------------------
| CLI Settings
|--------------------------------------------------------------------------
*/
public bool $cliColors = true;
public bool $cliForceUTF8 = false;
public bool $cliDetectWidth = true;
public int $cliMinWidth = 40;
}

151
app/Config/Logger.php Normal file
View File

@@ -0,0 +1,151 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Log\Handlers\FileHandler;
use CodeIgniter\Log\Handlers\HandlerInterface;
class Logger extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Error Logging Threshold
* --------------------------------------------------------------------------
*
* You can enable error logging by setting a threshold over zero. The
* threshold determines what gets logged. Any values below or equal to the
* threshold will be logged.
*
* Threshold options are:
*
* - 0 = Disables logging, Error logging TURNED OFF
* - 1 = Emergency Messages - System is unusable
* - 2 = Alert Messages - Action Must Be Taken Immediately
* - 3 = Critical Messages - Application component unavailable, unexpected exception.
* - 4 = Runtime Errors - Don't need immediate action, but should be monitored.
* - 5 = Warnings - Exceptional occurrences that are not errors.
* - 6 = Notices - Normal but significant events.
* - 7 = Info - Interesting events, like user logging in, etc.
* - 8 = Debug - Detailed debug information.
* - 9 = All Messages
*
* You can also pass an array with threshold levels to show individual error types
*
* array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
*
* For a live site you'll usually enable Critical or higher (3) to be logged otherwise
* your log files will fill up very fast.
*
* @var int|list<int>
*/
public $threshold = (ENVIRONMENT === 'production') ? 4 : 9;
/**
* --------------------------------------------------------------------------
* Date Format for Logs
* --------------------------------------------------------------------------
*
* Each item that is logged has an associated date. You can use PHP date
* codes to set your own date formatting
*/
public string $dateFormat = 'Y-m-d H:i:s';
/**
* --------------------------------------------------------------------------
* Log Handlers
* --------------------------------------------------------------------------
*
* The logging system supports multiple actions to be taken when something
* is logged. This is done by allowing for multiple Handlers, special classes
* designed to write the log to their chosen destinations, whether that is
* a file on the getServer, a cloud-based service, or even taking actions such
* as emailing the dev team.
*
* Each handler is defined by the class name used for that handler, and it
* MUST implement the `CodeIgniter\Log\Handlers\HandlerInterface` interface.
*
* The value of each key is an array of configuration items that are sent
* to the constructor of each handler. The only required configuration item
* is the 'handles' element, which must be an array of integer log levels.
* This is most easily handled by using the constants defined in the
* `Psr\Log\LogLevel` class.
*
* Handlers are executed in the order defined in this array, starting with
* the handler on top and continuing down.
*
* @var array<class-string<HandlerInterface>, array<string, int|list<string>|string>>
*/
public array $handlers = [
/*
* --------------------------------------------------------------------
* File Handler
* --------------------------------------------------------------------
*/
FileHandler::class => [
// The log levels that this handler will handle.
'handles' => [
'critical',
'alert',
'emergency',
'debug',
'error',
'info',
'notice',
'warning',
],
/*
* The default filename extension for log files.
* An extension of 'php' allows for protecting the log files via basic
* scripting, when they are to be stored under a publicly accessible directory.
*
* NOTE: Leaving it blank will default to 'log'.
*/
'fileExtension' => '',
/*
* The file system permissions to be applied on newly created log files.
*
* IMPORTANT: This MUST be an integer (no quotes) and you MUST use octal
* integer notation (i.e. 0700, 0644, etc.)
*/
'filePermissions' => 0644,
/*
* Logging Directory Path
*
* By default, logs are written to WRITEPATH . 'logs/'
* Specify a different destination here, if desired.
*/
'path' => '',
],
/*
* The ChromeLoggerHandler requires the use of the Chrome web browser
* and the ChromeLogger extension. Uncomment this block to use it.
*/
// 'CodeIgniter\Log\Handlers\ChromeLoggerHandler' => [
// /*
// * The log levels that this handler will handle.
// */
// 'handles' => ['critical', 'alert', 'emergency', 'debug',
// 'error', 'info', 'notice', 'warning'],
// ],
/*
* The ErrorlogHandler writes the logs to PHP's native `error_log()` function.
* Uncomment this block to use it.
*/
// 'CodeIgniter\Log\Handlers\ErrorlogHandler' => [
// /* The log levels this handler can handle. */
// 'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
//
// /*
// * The message type where the error should go. Can be 0 or 4, or use the
// * class constants: `ErrorlogHandler::TYPE_OS` (0) or `ErrorlogHandler::TYPE_SAPI` (4)
// */
// 'messageType' => 0,
// ],
];
}

50
app/Config/Migrations.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Migrations extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Enable/Disable Migrations
* --------------------------------------------------------------------------
*
* Migrations are enabled by default.
*
* You should enable migrations whenever you intend to do a schema migration
* and disable it back when you're done.
*/
public bool $enabled = true;
/**
* --------------------------------------------------------------------------
* Migrations Table
* --------------------------------------------------------------------------
*
* This is the name of the table that will store the current migrations state.
* When migrations runs it will store in a database table which migration
* files have already been run.
*/
public string $table = 'migrations';
/**
* --------------------------------------------------------------------------
* Timestamp Format
* --------------------------------------------------------------------------
*
* This is the format that will be used when creating new migrations
* using the CLI command:
* > php spark make:migration
*
* NOTE: if you set an unsupported format, migration runner will not find
* your migration files.
*
* Supported formats:
* - YmdHis_
* - Y-m-d-His_
* - Y_m_d_His_
*/
public string $timestampFormat = 'Y-m-d-His_';
}

534
app/Config/Mimes.php Normal file
View File

@@ -0,0 +1,534 @@
<?php
namespace Config;
/**
* This file contains an array of mime types. It is used by the
* Upload class to help identify allowed file types.
*
* When more than one variation for an extension exist (like jpg, jpeg, etc)
* the most common one should be first in the array to aid the guess*
* methods. The same applies when more than one mime-type exists for a
* single extension.
*
* When working with mime types, please make sure you have the ´fileinfo´
* extension enabled to reliably detect the media types.
*/
class Mimes
{
/**
* Map of extensions to mime types.
*
* @var array<string, list<string>|string>
*/
public static array $mimes = [
'hqx' => [
'application/mac-binhex40',
'application/mac-binhex',
'application/x-binhex40',
'application/x-mac-binhex40',
],
'cpt' => 'application/mac-compactpro',
'csv' => [
'text/csv',
'text/x-comma-separated-values',
'text/comma-separated-values',
'application/vnd.ms-excel',
'application/x-csv',
'text/x-csv',
'application/csv',
'application/excel',
'application/vnd.msexcel',
'text/plain',
],
'bin' => [
'application/macbinary',
'application/mac-binary',
'application/octet-stream',
'application/x-binary',
'application/x-macbinary',
],
'dms' => 'application/octet-stream',
'lha' => 'application/octet-stream',
'lzh' => 'application/octet-stream',
'exe' => [
'application/octet-stream',
'application/vnd.microsoft.portable-executable',
'application/x-dosexec',
'application/x-msdownload',
],
'class' => 'application/octet-stream',
'psd' => [
'application/x-photoshop',
'image/vnd.adobe.photoshop',
],
'so' => 'application/octet-stream',
'sea' => 'application/octet-stream',
'dll' => 'application/octet-stream',
'oda' => 'application/oda',
'pdf' => [
'application/pdf',
'application/force-download',
'application/x-download',
],
'ai' => [
'application/pdf',
'application/postscript',
],
'eps' => 'application/postscript',
'ps' => 'application/postscript',
'smi' => 'application/smil',
'smil' => 'application/smil',
'mif' => 'application/vnd.mif',
'xls' => [
'application/vnd.ms-excel',
'application/msexcel',
'application/x-msexcel',
'application/x-ms-excel',
'application/x-excel',
'application/x-dos_ms_excel',
'application/xls',
'application/x-xls',
'application/excel',
'application/download',
'application/vnd.ms-office',
'application/msword',
],
'ppt' => [
'application/vnd.ms-powerpoint',
'application/powerpoint',
'application/vnd.ms-office',
'application/msword',
],
'pptx' => [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
'wbxml' => 'application/wbxml',
'wmlc' => 'application/wmlc',
'dcr' => 'application/x-director',
'dir' => 'application/x-director',
'dxr' => 'application/x-director',
'dvi' => 'application/x-dvi',
'gtar' => 'application/x-gtar',
'gz' => 'application/x-gzip',
'gzip' => 'application/x-gzip',
'php' => [
'application/x-php',
'application/x-httpd-php',
'application/php',
'text/php',
'text/x-php',
'application/x-httpd-php-source',
],
'php4' => 'application/x-httpd-php',
'php3' => 'application/x-httpd-php',
'phtml' => 'application/x-httpd-php',
'phps' => 'application/x-httpd-php-source',
'js' => [
'application/x-javascript',
'text/plain',
],
'swf' => 'application/x-shockwave-flash',
'sit' => 'application/x-stuffit',
'tar' => 'application/x-tar',
'tgz' => [
'application/x-tar',
'application/x-gzip-compressed',
],
'z' => 'application/x-compress',
'xhtml' => 'application/xhtml+xml',
'xht' => 'application/xhtml+xml',
'zip' => [
'application/x-zip',
'application/zip',
'application/x-zip-compressed',
'application/s-compressed',
'multipart/x-zip',
],
'rar' => [
'application/vnd.rar',
'application/x-rar',
'application/rar',
'application/x-rar-compressed',
],
'mid' => 'audio/midi',
'midi' => 'audio/midi',
'mpga' => 'audio/mpeg',
'mp2' => 'audio/mpeg',
'mp3' => [
'audio/mpeg',
'audio/mpg',
'audio/mpeg3',
'audio/mp3',
],
'aif' => [
'audio/x-aiff',
'audio/aiff',
],
'aiff' => [
'audio/x-aiff',
'audio/aiff',
],
'aifc' => 'audio/x-aiff',
'ram' => 'audio/x-pn-realaudio',
'rm' => 'audio/x-pn-realaudio',
'rpm' => 'audio/x-pn-realaudio-plugin',
'ra' => 'audio/x-realaudio',
'rv' => 'video/vnd.rn-realvideo',
'wav' => [
'audio/x-wav',
'audio/wave',
'audio/wav',
],
'bmp' => [
'image/bmp',
'image/x-bmp',
'image/x-bitmap',
'image/x-xbitmap',
'image/x-win-bitmap',
'image/x-windows-bmp',
'image/ms-bmp',
'image/x-ms-bmp',
'application/bmp',
'application/x-bmp',
'application/x-win-bitmap',
],
'gif' => 'image/gif',
'jpg' => [
'image/jpeg',
'image/pjpeg',
],
'jpeg' => [
'image/jpeg',
'image/pjpeg',
],
'jpe' => [
'image/jpeg',
'image/pjpeg',
],
'jp2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'j2k' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpf' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpg2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpx' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpm' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'mj2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'mjp2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'png' => [
'image/png',
'image/x-png',
],
'webp' => 'image/webp',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'css' => [
'text/css',
'text/plain',
],
'html' => [
'text/html',
'text/plain',
],
'htm' => [
'text/html',
'text/plain',
],
'shtml' => [
'text/html',
'text/plain',
],
'txt' => 'text/plain',
'text' => 'text/plain',
'log' => [
'text/plain',
'text/x-log',
],
'rtx' => 'text/richtext',
'rtf' => 'text/rtf',
'xml' => [
'application/xml',
'text/xml',
'text/plain',
],
'xsl' => [
'application/xml',
'text/xsl',
'text/xml',
],
'mpeg' => 'video/mpeg',
'mpg' => 'video/mpeg',
'mpe' => 'video/mpeg',
'qt' => 'video/quicktime',
'mov' => 'video/quicktime',
'avi' => [
'video/x-msvideo',
'video/msvideo',
'video/avi',
'application/x-troff-msvideo',
],
'movie' => 'video/x-sgi-movie',
'doc' => [
'application/msword',
'application/vnd.ms-office',
],
'docx' => [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip',
'application/msword',
'application/x-zip',
],
'dot' => [
'application/msword',
'application/vnd.ms-office',
],
'dotx' => [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip',
'application/msword',
],
'xlsx' => [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/vnd.ms-excel',
'application/msword',
'application/x-zip',
],
'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
'word' => [
'application/msword',
'application/octet-stream',
],
'xl' => 'application/excel',
'eml' => 'message/rfc822',
'json' => [
'application/json',
'text/json',
],
'pem' => [
'application/x-x509-user-cert',
'application/x-pem-file',
'application/octet-stream',
],
'p10' => [
'application/x-pkcs10',
'application/pkcs10',
],
'p12' => 'application/x-pkcs12',
'p7a' => 'application/x-pkcs7-signature',
'p7c' => [
'application/pkcs7-mime',
'application/x-pkcs7-mime',
],
'p7m' => [
'application/pkcs7-mime',
'application/x-pkcs7-mime',
],
'p7r' => 'application/x-pkcs7-certreqresp',
'p7s' => 'application/pkcs7-signature',
'crt' => [
'application/x-x509-ca-cert',
'application/x-x509-user-cert',
'application/pkix-cert',
],
'crl' => [
'application/pkix-crl',
'application/pkcs-crl',
],
'der' => 'application/x-x509-ca-cert',
'kdb' => 'application/octet-stream',
'pgp' => 'application/pgp',
'gpg' => 'application/gpg-keys',
'sst' => 'application/octet-stream',
'csr' => 'application/octet-stream',
'rsa' => 'application/x-pkcs7',
'cer' => [
'application/pkix-cert',
'application/x-x509-ca-cert',
],
'3g2' => 'video/3gpp2',
'3gp' => [
'video/3gp',
'video/3gpp',
],
'mp4' => 'video/mp4',
'm4a' => 'audio/x-m4a',
'f4v' => [
'video/mp4',
'video/x-f4v',
],
'flv' => 'video/x-flv',
'webm' => 'video/webm',
'aac' => 'audio/x-acc',
'm4u' => 'application/vnd.mpegurl',
'm3u' => 'text/plain',
'xspf' => 'application/xspf+xml',
'vlc' => 'application/videolan',
'wmv' => [
'video/x-ms-wmv',
'video/x-ms-asf',
],
'au' => 'audio/x-au',
'ac3' => 'audio/ac3',
'flac' => 'audio/x-flac',
'ogg' => [
'audio/ogg',
'video/ogg',
'application/ogg',
],
'kmz' => [
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip',
],
'kml' => [
'application/vnd.google-earth.kml+xml',
'application/xml',
'text/xml',
],
'ics' => 'text/calendar',
'ical' => 'text/calendar',
'zsh' => 'text/x-scriptzsh',
'7zip' => [
'application/x-compressed',
'application/x-zip-compressed',
'application/zip',
'multipart/x-zip',
],
'cdr' => [
'application/cdr',
'application/coreldraw',
'application/x-cdr',
'application/x-coreldraw',
'image/cdr',
'image/x-cdr',
'zz-application/zz-winassoc-cdr',
],
'wma' => [
'audio/x-ms-wma',
'video/x-ms-asf',
],
'jar' => [
'application/java-archive',
'application/x-java-application',
'application/x-jar',
'application/x-compressed',
],
'svg' => [
'image/svg+xml',
'image/svg',
'application/xml',
'text/xml',
],
'vcf' => 'text/x-vcard',
'srt' => [
'text/srt',
'text/plain',
],
'vtt' => [
'text/vtt',
'text/plain',
],
'ico' => [
'image/x-icon',
'image/x-ico',
'image/vnd.microsoft.icon',
],
'stl' => [
'application/sla',
'application/vnd.ms-pki.stl',
'application/x-navistyle',
'model/stl',
'application/octet-stream',
],
];
/**
* Attempts to determine the best mime type for the given file extension.
*
* @return string|null The mime type found, or none if unable to determine.
*/
public static function guessTypeFromExtension(string $extension)
{
$extension = trim(strtolower($extension), '. ');
if (! array_key_exists($extension, static::$mimes)) {
return null;
}
return is_array(static::$mimes[$extension]) ? static::$mimes[$extension][0] : static::$mimes[$extension];
}
/**
* Attempts to determine the best file extension for a given mime type.
*
* @param string|null $proposedExtension - default extension (in case there is more than one with the same mime type)
*
* @return string|null The extension determined, or null if unable to match.
*/
public static function guessExtensionFromType(string $type, ?string $proposedExtension = null)
{
$type = trim(strtolower($type), '. ');
$proposedExtension = trim(strtolower($proposedExtension ?? ''));
if (
$proposedExtension !== ''
&& array_key_exists($proposedExtension, static::$mimes)
&& in_array($type, (array) static::$mimes[$proposedExtension], true)
) {
// The detected mime type matches with the proposed extension.
return $proposedExtension;
}
// Reverse check the mime type list if no extension was proposed.
// This search is order sensitive!
foreach (static::$mimes as $ext => $types) {
if (in_array($type, (array) $types, true)) {
return $ext;
}
}
return null;
}
}

82
app/Config/Modules.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
namespace Config;
use CodeIgniter\Modules\Modules as BaseModules;
/**
* Modules Configuration.
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*/
class Modules extends BaseModules
{
/**
* --------------------------------------------------------------------------
* Enable Auto-Discovery?
* --------------------------------------------------------------------------
*
* If true, then auto-discovery will happen across all elements listed in
* $aliases below. If false, no auto-discovery will happen at all,
* giving a slight performance boost.
*
* @var bool
*/
public $enabled = true;
/**
* --------------------------------------------------------------------------
* Enable Auto-Discovery Within Composer Packages?
* --------------------------------------------------------------------------
*
* If true, then auto-discovery will happen across all namespaces loaded
* by Composer, as well as the namespaces configured locally.
*
* @var bool
*/
public $discoverInComposer = true;
/**
* The Composer package list for Auto-Discovery
* This setting is optional.
*
* E.g.:
* [
* 'only' => [
* // List up all packages to auto-discover
* 'codeigniter4/shield',
* ],
* ]
* or
* [
* 'exclude' => [
* // List up packages to exclude.
* 'pestphp/pest',
* ],
* ]
*
* @var array{only?: list<string>, exclude?: list<string>}
*/
public $composerPackages = [];
/**
* --------------------------------------------------------------------------
* Auto-Discovery Rules
* --------------------------------------------------------------------------
*
* Aliases list of all discovery classes that will be active and used during
* the current application request.
*
* If it is not listed, only the base application elements will be used.
*
* @var list<string>
*/
public $aliases = [
'events',
'filters',
'registrars',
'routes',
'services',
];
}

30
app/Config/Optimize.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace Config;
/**
* Optimization Configuration.
*
* NOTE: This class does not extend BaseConfig for performance reasons.
* So you cannot replace the property values with Environment Variables.
*/
class Optimize
{
/**
* --------------------------------------------------------------------------
* Config Caching
* --------------------------------------------------------------------------
*
* @see https://codeigniter.com/user_guide/concepts/factories.html#config-caching
*/
public bool $configCacheEnabled = false;
/**
* --------------------------------------------------------------------------
* Config Caching
* --------------------------------------------------------------------------
*
* @see https://codeigniter.com/user_guide/concepts/autoloader.html#file-locator-caching
*/
public bool $locatorCacheEnabled = false;
}

37
app/Config/Pager.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Pager extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Templates
* --------------------------------------------------------------------------
*
* Pagination links are rendered out using views to configure their
* appearance. This array contains aliases and the view names to
* use when rendering the links.
*
* Within each view, the Pager object will be available as $pager,
* and the desired group as $pagerGroup;
*
* @var array<string, string>
*/
public array $templates = [
'default_full' => 'CodeIgniter\Pager\Views\default_full',
'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
'default_head' => 'CodeIgniter\Pager\Views\default_head',
];
/**
* --------------------------------------------------------------------------
* Items Per Page
* --------------------------------------------------------------------------
*
* The default number of results shown in a single page.
*/
public int $perPage = 20;
}

78
app/Config/Paths.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
namespace Config;
/**
* Paths
*
* Holds the paths that are used by the system to
* locate the main directories, app, system, etc.
*
* Modifying these allows you to restructure your application,
* share a system folder between multiple applications, and more.
*
* All paths are relative to the project's root folder.
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*/
class Paths
{
/**
* ---------------------------------------------------------------
* SYSTEM FOLDER NAME
* ---------------------------------------------------------------
*
* This must contain the name of your "system" folder. Include
* the path if the folder is not in the same directory as this file.
*/
public string $systemDirectory = __DIR__ . '/../../vendor/codeigniter4/framework/system';
/**
* ---------------------------------------------------------------
* APPLICATION FOLDER NAME
* ---------------------------------------------------------------
*
* If you want this front controller to use a different "app"
* folder than the default one you can set its name here. The folder
* can also be renamed or relocated anywhere on your server. If
* you do, use a full server path.
*
* @see http://codeigniter.com/user_guide/general/managing_apps.html
*/
public string $appDirectory = __DIR__ . '/..';
/**
* ---------------------------------------------------------------
* WRITABLE DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of your "writable" directory.
* The writable directory allows you to group all directories that
* need write permission to a single place that can be tucked away
* for maximum security, keeping it out of the app and/or
* system directories.
*/
public string $writableDirectory = __DIR__ . '/../../writable';
/**
* ---------------------------------------------------------------
* TESTS DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of your "tests" directory.
*/
public string $testsDirectory = __DIR__ . '/../../tests';
/**
* ---------------------------------------------------------------
* VIEW DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of the directory that
* contains the view files used by your application. By
* default this is in `app/Views`. This value
* is used when no value is provided to `Services::renderer()`.
*/
public string $viewDirectory = __DIR__ . '/../Views';
}

28
app/Config/Publisher.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace Config;
use CodeIgniter\Config\Publisher as BasePublisher;
/**
* Publisher Configuration
*
* Defines basic security restrictions for the Publisher class
* to prevent abuse by injecting malicious files into a project.
*/
class Publisher extends BasePublisher
{
/**
* A list of allowed destinations with a (pseudo-)regex
* of allowed files for each destination.
* Attempts to publish to directories not in this list will
* result in a PublisherException. Files that do no fit the
* pattern will cause copy/merge to fail.
*
* @var array<string, string>
*/
public $restrictions = [
ROOTPATH => '*',
FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i',
];
}

73
app/Config/Routes.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
use CodeIgniter\Router\RouteCollection;
/**
* @var RouteCollection $routes
*/
$routes->get('/', 'Home::index');
// Auth Routes (public, not protected)
// NOTE: Throttle filter DISABLED untuk login - rate limiting di-handle di controller berdasarkan failed attempts
$routes->group('auth', function($routes) {
$routes->get('login', 'AuthController::login');
$routes->post('login', 'AuthController::login'); // No throttle filter - handled in controller
$routes->get('logout', 'AuthController::logout');
});
// Admin Routes (protected by auth filter)
$routes->group('admin', ['filter' => 'auth'], function($routes) {
$routes->get('/', 'Admin\Dashboard::index');
$routes->get('dashboard', 'Admin\Dashboard::index');
// News Routes
$routes->group('news', function($routes) {
$routes->get('/', 'Admin\News::index');
$routes->get('create', 'Admin\News::create');
$routes->post('store', 'Admin\News::store');
$routes->get('edit/(:num)', 'Admin\News::edit/$1');
$routes->post('update/(:num)', 'Admin\News::update/$1');
$routes->post('delete/(:num)', 'Admin\News::delete/$1');
});
// Pages Routes
$routes->group('pages', function($routes) {
$routes->get('/', 'Admin\Pages::index');
$routes->get('create', 'Admin\Pages::create');
$routes->post('store', 'Admin\Pages::store');
$routes->get('edit/(:num)', 'Admin\Pages::edit/$1');
$routes->post('update/(:num)', 'Admin\Pages::update/$1');
$routes->post('autosave/(:num)', 'Admin\Pages::autosave/$1');
$routes->post('delete/(:num)', 'Admin\Pages::delete/$1');
});
// Upload route
$routes->post('upload', 'Admin\Pages::upload', ['filter' => 'auth']);
// Users Routes (admin only)
$routes->group('users', ['filter' => 'auth:admin'], function($routes) {
$routes->get('/', 'Admin\Users::index');
$routes->get('create', 'Admin\Users::create');
$routes->post('store', 'Admin\Users::store');
$routes->get('edit/(:num)', 'Admin\Users::edit/$1');
$routes->post('update/(:num)', 'Admin\Users::update/$1');
$routes->post('reset-password/(:num)', 'Admin\Users::resetPassword/$1');
$routes->post('toggle-active/(:num)', 'Admin\Users::toggleActive/$1');
$routes->post('delete/(:num)', 'Admin\Users::delete/$1');
});
// Audit Logs Routes (admin only)
$routes->group('audit-logs', ['filter' => 'auth:admin'], function($routes) {
$routes->get('/', 'Admin\AuditLogs::index');
});
// Profile Routes
$routes->get('profile', 'Admin\Profile::index');
$routes->post('profile/update', 'Admin\Profile::update');
// Settings Routes (admin only)
$routes->group('settings', ['filter' => 'auth:admin'], function($routes) {
$routes->get('/', 'Admin\Settings::index');
$routes->post('update', 'Admin\Settings::update');
});
});

140
app/Config/Routing.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Config;
use CodeIgniter\Config\Routing as BaseRouting;
/**
* Routing configuration
*/
class Routing extends BaseRouting
{
/**
* For Defined Routes.
* An array of files that contain route definitions.
* Route files are read in order, with the first match
* found taking precedence.
*
* Default: APPPATH . 'Config/Routes.php'
*
* @var list<string>
*/
public array $routeFiles = [
APPPATH . 'Config/Routes.php',
];
/**
* For Defined Routes and Auto Routing.
* The default namespace to use for Controllers when no other
* namespace has been specified.
*
* Default: 'App\Controllers'
*/
public string $defaultNamespace = 'App\Controllers';
/**
* For Auto Routing.
* The default controller to use when no other controller has been
* specified.
*
* Default: 'Home'
*/
public string $defaultController = 'Home';
/**
* For Defined Routes and Auto Routing.
* The default method to call on the controller when no other
* method has been set in the route.
*
* Default: 'index'
*/
public string $defaultMethod = 'index';
/**
* For Auto Routing.
* Whether to translate dashes in URIs for controller/method to underscores.
* Primarily useful when using the auto-routing.
*
* Default: false
*/
public bool $translateURIDashes = false;
/**
* Sets the class/method that should be called if routing doesn't
* find a match. It can be the controller/method name like: Users::index
*
* This setting is passed to the Router class and handled there.
*
* If you want to use a closure, you will have to set it in the
* routes file by calling:
*
* $routes->set404Override(function() {
* // Do something here
* });
*
* Example:
* public $override404 = 'App\Errors::show404';
*/
public ?string $override404 = null;
/**
* If TRUE, the system will attempt to match the URI against
* Controllers by matching each segment against folders/files
* in APPPATH/Controllers, when a match wasn't found against
* defined routes.
*
* If FALSE, will stop searching and do NO automatic routing.
*/
public bool $autoRoute = false;
/**
* For Defined Routes.
* If TRUE, will enable the use of the 'prioritize' option
* when defining routes.
*
* Default: false
*/
public bool $prioritize = false;
/**
* For Defined Routes.
* If TRUE, matched multiple URI segments will be passed as one parameter.
*
* Default: false
*/
public bool $multipleSegmentsOneParam = false;
/**
* For Auto Routing (Improved).
* Map of URI segments and namespaces.
*
* The key is the first URI segment. The value is the controller namespace.
* E.g.,
* [
* 'blog' => 'Acme\Blog\Controllers',
* ]
*
* @var array<string, string>
*/
public array $moduleRoutes = [];
/**
* For Auto Routing (Improved).
* Whether to translate dashes in URIs for controller/method to CamelCase.
* E.g., blog-controller -> BlogController
*
* If you enable this, $translateURIDashes is ignored.
*
* Default: false
*/
public bool $translateUriToCamelCase = true;
}

86
app/Config/Security.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Security extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* CSRF Protection Method
* --------------------------------------------------------------------------
*
* Protection Method for Cross Site Request Forgery protection.
*
* @var string 'cookie' or 'session'
*/
public string $csrfProtection = 'cookie';
/**
* --------------------------------------------------------------------------
* CSRF Token Randomization
* --------------------------------------------------------------------------
*
* Randomize the CSRF Token for added security.
*/
public bool $tokenRandomize = true;
/**
* --------------------------------------------------------------------------
* CSRF Token Name
* --------------------------------------------------------------------------
*
* Token name for Cross Site Request Forgery protection.
*/
public string $tokenName = 'csrf_test_name';
/**
* --------------------------------------------------------------------------
* CSRF Header Name
* --------------------------------------------------------------------------
*
* Header name for Cross Site Request Forgery protection.
*/
public string $headerName = 'X-CSRF-TOKEN';
/**
* --------------------------------------------------------------------------
* CSRF Cookie Name
* --------------------------------------------------------------------------
*
* Cookie name for Cross Site Request Forgery protection.
*/
public string $cookieName = 'csrf_cookie_name';
/**
* --------------------------------------------------------------------------
* CSRF Expires
* --------------------------------------------------------------------------
*
* Expiration time for Cross Site Request Forgery protection cookie.
*
* Defaults to two hours (in seconds).
*/
public int $expires = 7200;
/**
* --------------------------------------------------------------------------
* CSRF Regenerate
* --------------------------------------------------------------------------
*
* Regenerate CSRF Token on every submission.
*/
public bool $regenerate = true;
/**
* --------------------------------------------------------------------------
* CSRF Redirect
* --------------------------------------------------------------------------
*
* Redirect to previous page with error on failure.
*
* @see https://codeigniter4.github.io/userguide/libraries/security.html#redirection-on-failure
*/
public bool $redirect = false; // Set to false to show error message instead of redirect
}

32
app/Config/Services.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseService;
/**
* Services Configuration file.
*
* Services are simply other classes/libraries that the system uses
* to do its job. This is used by CodeIgniter to allow the core of the
* framework to be swapped out easily without affecting the usage within
* the rest of your application.
*
* This file holds any application-specific services, or service overrides
* that you might need. An example has been included with the general
* method format you should use for your service methods. For more examples,
* see the core Services file at system/Config/Services.php.
*/
class Services extends BaseService
{
/*
* public static function example($getShared = true)
* {
* if ($getShared) {
* return static::getSharedInstance('example');
* }
*
* return new \CodeIgniter\Example();
* }
*/
}

127
app/Config/Session.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\FileHandler;
class Session extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Session Driver
* --------------------------------------------------------------------------
*
* The session storage driver to use:
* - `CodeIgniter\Session\Handlers\FileHandler`
* - `CodeIgniter\Session\Handlers\DatabaseHandler`
* - `CodeIgniter\Session\Handlers\MemcachedHandler`
* - `CodeIgniter\Session\Handlers\RedisHandler`
*
* @var class-string<BaseHandler>
*/
public string $driver = FileHandler::class;
/**
* --------------------------------------------------------------------------
* Session Cookie Name
* --------------------------------------------------------------------------
*
* The session cookie name, must contain only [0-9a-z_-] characters
*/
public string $cookieName = 'bapenda_cms_session';
/**
* --------------------------------------------------------------------------
* Session Expiration
* --------------------------------------------------------------------------
*
* The number of SECONDS you want the session to last.
* Setting to 0 (zero) means expire when the browser is closed.
*/
public int $expiration = 7200;
/**
* --------------------------------------------------------------------------
* Session Save Path
* --------------------------------------------------------------------------
*
* The location to save sessions to and is driver dependent.
*
* For the 'files' driver, it's a path to a writable directory.
* WARNING: Only absolute paths are supported!
*
* For the 'database' driver, it's a table name.
* Please read up the manual for the format with other session drivers.
*
* IMPORTANT: You are REQUIRED to set a valid save path!
*/
public string $savePath = WRITEPATH . 'session';
/**
* --------------------------------------------------------------------------
* Session Match IP
* --------------------------------------------------------------------------
*
* Whether to match the user's IP address when reading the session data.
*
* WARNING: If you're using the database driver, don't forget to update
* your session table's PRIMARY KEY when changing this setting.
*/
public bool $matchIP = false;
/**
* --------------------------------------------------------------------------
* Session Time to Update
* --------------------------------------------------------------------------
*
* How many seconds between CI regenerating the session ID.
*/
public int $timeToUpdate = 300;
/**
* --------------------------------------------------------------------------
* Session Regenerate Destroy
* --------------------------------------------------------------------------
*
* Whether to destroy session data associated with the old session ID
* when auto-regenerating the session ID. When set to FALSE, the data
* will be later deleted by the garbage collector.
*/
public bool $regenerateDestroy = true;
/**
* --------------------------------------------------------------------------
* Session Database Group
* --------------------------------------------------------------------------
*
* DB Group for the database session.
*/
public ?string $DBGroup = null;
/**
* --------------------------------------------------------------------------
* Lock Retry Interval (microseconds)
* --------------------------------------------------------------------------
*
* This is used for RedisHandler.
*
* Time (microseconds) to wait if lock cannot be acquired.
* The default is 100,000 microseconds (= 0.1 seconds).
*/
public int $lockRetryInterval = 100_000;
/**
* --------------------------------------------------------------------------
* Lock Max Retries
* --------------------------------------------------------------------------
*
* This is used for RedisHandler.
*
* Maximum number of lock acquisition attempts.
* The default is 300 times. That is lock timeout is about 30 (0.1 * 300)
* seconds.
*/
public int $lockMaxRetries = 300;
}

38
app/Config/Throttler.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Konfigurasi Throttler untuk Rate Limiting
*
* Digunakan untuk mencegah brute force attacks pada login
* dan endpoint lainnya yang memerlukan rate limiting.
*/
class Throttler extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Token Prefix
* --------------------------------------------------------------------------
*
* Prefix untuk token yang digunakan dalam cache key.
* Membantu menghindari collision dengan cache key lainnya.
*/
public string $prefix = 'throttler_';
/**
* --------------------------------------------------------------------------
* Error Messages
* --------------------------------------------------------------------------
*
* Pesan error yang akan ditampilkan ketika rate limit tercapai.
* Dapat disesuaikan sesuai kebutuhan aplikasi.
*/
public array $errorMessages = [
'en' => 'Too Many Requests',
'id' => 'Terlalu banyak percobaan. Silakan coba lagi nanti.',
];
}

122
app/Config/Toolbar.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Debug\Toolbar\Collectors\Database;
use CodeIgniter\Debug\Toolbar\Collectors\Events;
use CodeIgniter\Debug\Toolbar\Collectors\Files;
use CodeIgniter\Debug\Toolbar\Collectors\Logs;
use CodeIgniter\Debug\Toolbar\Collectors\Routes;
use CodeIgniter\Debug\Toolbar\Collectors\Timers;
use CodeIgniter\Debug\Toolbar\Collectors\Views;
/**
* --------------------------------------------------------------------------
* Debug Toolbar
* --------------------------------------------------------------------------
*
* The Debug Toolbar provides a way to see information about the performance
* and state of your application during that page display. By default it will
* NOT be displayed under production environments, and will only display if
* `CI_DEBUG` is true, since if it's not, there's not much to display anyway.
*/
class Toolbar extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Toolbar Collectors
* --------------------------------------------------------------------------
*
* List of toolbar collectors that will be called when Debug Toolbar
* fires up and collects data from.
*
* @var list<class-string>
*/
public array $collectors = [
Timers::class,
Database::class,
Logs::class,
Views::class,
// \CodeIgniter\Debug\Toolbar\Collectors\Cache::class,
Files::class,
Routes::class,
Events::class,
];
/**
* --------------------------------------------------------------------------
* Collect Var Data
* --------------------------------------------------------------------------
*
* If set to false var data from the views will not be collected. Useful to
* avoid high memory usage when there are lots of data passed to the view.
*/
public bool $collectVarData = true;
/**
* --------------------------------------------------------------------------
* Max History
* --------------------------------------------------------------------------
*
* `$maxHistory` sets a limit on the number of past requests that are stored,
* helping to conserve file space used to store them. You can set it to
* 0 (zero) to not have any history stored, or -1 for unlimited history.
*/
public int $maxHistory = 20;
/**
* --------------------------------------------------------------------------
* Toolbar Views Path
* --------------------------------------------------------------------------
*
* The full path to the the views that are used by the toolbar.
* This MUST have a trailing slash.
*/
public string $viewsPath = SYSTEMPATH . 'Debug/Toolbar/Views/';
/**
* --------------------------------------------------------------------------
* Max Queries
* --------------------------------------------------------------------------
*
* If the Database Collector is enabled, it will log every query that the
* the system generates so they can be displayed on the toolbar's timeline
* and in the query log. This can lead to memory issues in some instances
* with hundreds of queries.
*
* `$maxQueries` defines the maximum amount of queries that will be stored.
*/
public int $maxQueries = 100;
/**
* --------------------------------------------------------------------------
* Watched Directories
* --------------------------------------------------------------------------
*
* Contains an array of directories that will be watched for changes and
* used to determine if the hot-reload feature should reload the page or not.
* We restrict the values to keep performance as high as possible.
*
* NOTE: The ROOTPATH will be prepended to all values.
*
* @var list<string>
*/
public array $watchedDirectories = [
'app',
];
/**
* --------------------------------------------------------------------------
* Watched File Extensions
* --------------------------------------------------------------------------
*
* Contains an array of file extensions that will be watched for changes and
* used to determine if the hot-reload feature should reload the page or not.
*
* @var list<string>
*/
public array $watchedExtensions = [
'php', 'css', 'js', 'html', 'svg', 'json', 'env',
];
}

252
app/Config/UserAgents.php Normal file
View File

@@ -0,0 +1,252 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* -------------------------------------------------------------------
* User Agents
* -------------------------------------------------------------------
*
* This file contains four arrays of user agent data. It is used by the
* User Agent Class to help identify browser, platform, robot, and
* mobile device data. The array keys are used to identify the device
* and the array values are used to set the actual name of the item.
*/
class UserAgents extends BaseConfig
{
/**
* -------------------------------------------------------------------
* OS Platforms
* -------------------------------------------------------------------
*
* @var array<string, string>
*/
public array $platforms = [
'windows nt 10.0' => 'Windows 10',
'windows nt 6.3' => 'Windows 8.1',
'windows nt 6.2' => 'Windows 8',
'windows nt 6.1' => 'Windows 7',
'windows nt 6.0' => 'Windows Vista',
'windows nt 5.2' => 'Windows 2003',
'windows nt 5.1' => 'Windows XP',
'windows nt 5.0' => 'Windows 2000',
'windows nt 4.0' => 'Windows NT 4.0',
'winnt4.0' => 'Windows NT 4.0',
'winnt 4.0' => 'Windows NT',
'winnt' => 'Windows NT',
'windows 98' => 'Windows 98',
'win98' => 'Windows 98',
'windows 95' => 'Windows 95',
'win95' => 'Windows 95',
'windows phone' => 'Windows Phone',
'windows' => 'Unknown Windows OS',
'android' => 'Android',
'blackberry' => 'BlackBerry',
'iphone' => 'iOS',
'ipad' => 'iOS',
'ipod' => 'iOS',
'os x' => 'Mac OS X',
'ppc mac' => 'Power PC Mac',
'freebsd' => 'FreeBSD',
'ppc' => 'Macintosh',
'linux' => 'Linux',
'debian' => 'Debian',
'sunos' => 'Sun Solaris',
'beos' => 'BeOS',
'apachebench' => 'ApacheBench',
'aix' => 'AIX',
'irix' => 'Irix',
'osf' => 'DEC OSF',
'hp-ux' => 'HP-UX',
'netbsd' => 'NetBSD',
'bsdi' => 'BSDi',
'openbsd' => 'OpenBSD',
'gnu' => 'GNU/Linux',
'unix' => 'Unknown Unix OS',
'symbian' => 'Symbian OS',
];
/**
* -------------------------------------------------------------------
* Browsers
* -------------------------------------------------------------------
*
* The order of this array should NOT be changed. Many browsers return
* multiple browser types so we want to identify the subtype first.
*
* @var array<string, string>
*/
public array $browsers = [
'OPR' => 'Opera',
'Flock' => 'Flock',
'Edge' => 'Spartan',
'Edg' => 'Edge',
'Chrome' => 'Chrome',
// Opera 10+ always reports Opera/9.80 and appends Version/<real version> to the user agent string
'Opera.*?Version' => 'Opera',
'Opera' => 'Opera',
'MSIE' => 'Internet Explorer',
'Internet Explorer' => 'Internet Explorer',
'Trident.* rv' => 'Internet Explorer',
'Shiira' => 'Shiira',
'Firefox' => 'Firefox',
'Chimera' => 'Chimera',
'Phoenix' => 'Phoenix',
'Firebird' => 'Firebird',
'Camino' => 'Camino',
'Netscape' => 'Netscape',
'OmniWeb' => 'OmniWeb',
'Safari' => 'Safari',
'Mozilla' => 'Mozilla',
'Konqueror' => 'Konqueror',
'icab' => 'iCab',
'Lynx' => 'Lynx',
'Links' => 'Links',
'hotjava' => 'HotJava',
'amaya' => 'Amaya',
'IBrowse' => 'IBrowse',
'Maxthon' => 'Maxthon',
'Ubuntu' => 'Ubuntu Web Browser',
'Vivaldi' => 'Vivaldi',
];
/**
* -------------------------------------------------------------------
* Mobiles
* -------------------------------------------------------------------
*
* @var array<string, string>
*/
public array $mobiles = [
// legacy array, old values commented out
'mobileexplorer' => 'Mobile Explorer',
// 'openwave' => 'Open Wave',
// 'opera mini' => 'Opera Mini',
// 'operamini' => 'Opera Mini',
// 'elaine' => 'Palm',
'palmsource' => 'Palm',
// 'digital paths' => 'Palm',
// 'avantgo' => 'Avantgo',
// 'xiino' => 'Xiino',
'palmscape' => 'Palmscape',
// 'nokia' => 'Nokia',
// 'ericsson' => 'Ericsson',
// 'blackberry' => 'BlackBerry',
// 'motorola' => 'Motorola'
// Phones and Manufacturers
'motorola' => 'Motorola',
'nokia' => 'Nokia',
'palm' => 'Palm',
'iphone' => 'Apple iPhone',
'ipad' => 'iPad',
'ipod' => 'Apple iPod Touch',
'sony' => 'Sony Ericsson',
'ericsson' => 'Sony Ericsson',
'blackberry' => 'BlackBerry',
'cocoon' => 'O2 Cocoon',
'blazer' => 'Treo',
'lg' => 'LG',
'amoi' => 'Amoi',
'xda' => 'XDA',
'mda' => 'MDA',
'vario' => 'Vario',
'htc' => 'HTC',
'samsung' => 'Samsung',
'sharp' => 'Sharp',
'sie-' => 'Siemens',
'alcatel' => 'Alcatel',
'benq' => 'BenQ',
'ipaq' => 'HP iPaq',
'mot-' => 'Motorola',
'playstation portable' => 'PlayStation Portable',
'playstation 3' => 'PlayStation 3',
'playstation vita' => 'PlayStation Vita',
'hiptop' => 'Danger Hiptop',
'nec-' => 'NEC',
'panasonic' => 'Panasonic',
'philips' => 'Philips',
'sagem' => 'Sagem',
'sanyo' => 'Sanyo',
'spv' => 'SPV',
'zte' => 'ZTE',
'sendo' => 'Sendo',
'nintendo dsi' => 'Nintendo DSi',
'nintendo ds' => 'Nintendo DS',
'nintendo 3ds' => 'Nintendo 3DS',
'wii' => 'Nintendo Wii',
'open web' => 'Open Web',
'openweb' => 'OpenWeb',
// Operating Systems
'android' => 'Android',
'symbian' => 'Symbian',
'SymbianOS' => 'SymbianOS',
'elaine' => 'Palm',
'series60' => 'Symbian S60',
'windows ce' => 'Windows CE',
// Browsers
'obigo' => 'Obigo',
'netfront' => 'Netfront Browser',
'openwave' => 'Openwave Browser',
'mobilexplorer' => 'Mobile Explorer',
'operamini' => 'Opera Mini',
'opera mini' => 'Opera Mini',
'opera mobi' => 'Opera Mobile',
'fennec' => 'Firefox Mobile',
// Other
'digital paths' => 'Digital Paths',
'avantgo' => 'AvantGo',
'xiino' => 'Xiino',
'novarra' => 'Novarra Transcoder',
'vodafone' => 'Vodafone',
'docomo' => 'NTT DoCoMo',
'o2' => 'O2',
// Fallback
'mobile' => 'Generic Mobile',
'wireless' => 'Generic Mobile',
'j2me' => 'Generic Mobile',
'midp' => 'Generic Mobile',
'cldc' => 'Generic Mobile',
'up.link' => 'Generic Mobile',
'up.browser' => 'Generic Mobile',
'smartphone' => 'Generic Mobile',
'cellphone' => 'Generic Mobile',
];
/**
* -------------------------------------------------------------------
* Robots
* -------------------------------------------------------------------
*
* There are hundred of bots but these are the most common.
*
* @var array<string, string>
*/
public array $robots = [
'googlebot' => 'Googlebot',
'msnbot' => 'MSNBot',
'baiduspider' => 'Baiduspider',
'bingbot' => 'Bing',
'slurp' => 'Inktomi Slurp',
'yahoo' => 'Yahoo',
'ask jeeves' => 'Ask Jeeves',
'fastcrawler' => 'FastCrawler',
'infoseek' => 'InfoSeek Robot 1.0',
'lycos' => 'Lycos',
'yandex' => 'YandexBot',
'mediapartners-google' => 'MediaPartners Google',
'CRAZYWEBCRAWLER' => 'Crazy Webcrawler',
'adsbot-google' => 'AdsBot Google',
'feedfetcher-google' => 'Feedfetcher Google',
'curious george' => 'Curious George',
'ia_archiver' => 'Alexa Crawler',
'MJ12bot' => 'Majestic-12',
'Uptimebot' => 'Uptimebot',
];
}

44
app/Config/Validation.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Validation\StrictRules\CreditCardRules;
use CodeIgniter\Validation\StrictRules\FileRules;
use CodeIgniter\Validation\StrictRules\FormatRules;
use CodeIgniter\Validation\StrictRules\Rules;
class Validation extends BaseConfig
{
// --------------------------------------------------------------------
// Setup
// --------------------------------------------------------------------
/**
* Stores the classes that contain the
* rules that are available.
*
* @var list<string>
*/
public array $ruleSets = [
Rules::class,
FormatRules::class,
FileRules::class,
CreditCardRules::class,
];
/**
* Specifies the views that are used to display the
* errors.
*
* @var array<string, string>
*/
public array $templates = [
'list' => 'CodeIgniter\Validation\Views\list',
'single' => 'CodeIgniter\Validation\Views\single',
];
// --------------------------------------------------------------------
// Rules
// --------------------------------------------------------------------
}

62
app/Config/View.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
namespace Config;
use CodeIgniter\Config\View as BaseView;
use CodeIgniter\View\ViewDecoratorInterface;
/**
* @phpstan-type parser_callable (callable(mixed): mixed)
* @phpstan-type parser_callable_string (callable(mixed): mixed)&string
*/
class View extends BaseView
{
/**
* When false, the view method will clear the data between each
* call. This keeps your data safe and ensures there is no accidental
* leaking between calls, so you would need to explicitly pass the data
* to each view. You might prefer to have the data stick around between
* calls so that it is available to all views. If that is the case,
* set $saveData to true.
*
* @var bool
*/
public $saveData = true;
/**
* Parser Filters map a filter name with any PHP callable. When the
* Parser prepares a variable for display, it will chain it
* through the filters in the order defined, inserting any parameters.
* To prevent potential abuse, all filters MUST be defined here
* in order for them to be available for use within the Parser.
*
* Examples:
* { title|esc(js) }
* { created_on|date(Y-m-d)|esc(attr) }
*
* @var array<string, string>
* @phpstan-var array<string, parser_callable_string>
*/
public $filters = [];
/**
* Parser Plugins provide a way to extend the functionality provided
* by the core Parser by creating aliases that will be replaced with
* any callable. Can be single or tag pair.
*
* @var array<string, callable|list<string>|string>
* @phpstan-var array<string, list<parser_callable_string>|parser_callable_string|parser_callable>
*/
public $plugins = [];
/**
* View Decorators are class methods that will be run in sequence to
* have a chance to alter the generated output just prior to caching
* the results.
*
* All classes must implement CodeIgniter\View\ViewDecoratorInterface
*
* @var list<class-string<ViewDecoratorInterface>>
*/
public array $decorators = [];
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\AuditLogModel;
use App\Models\UserModel;
class AuditLogs extends BaseController
{
protected $auditLogModel;
protected $userModel;
public function __construct()
{
$this->auditLogModel = new AuditLogModel();
$this->userModel = new UserModel();
}
/**
* Display audit logs with pagination
*/
public function index()
{
// Check if user is admin
if (session()->get('role') !== 'admin') {
return redirect()->to('/admin/dashboard')
->with('error', 'Anda tidak memiliki akses ke halaman ini.');
}
// Get search query
$search = $this->request->getGet('search');
$actionFilter = $this->request->getGet('action');
$userFilter = $this->request->getGet('user');
// Build query
$this->auditLogModel->select('audit_logs.*, users.username, users.email')
->join('users', 'users.id = audit_logs.user_id', 'left')
->orderBy('audit_logs.created_at', 'DESC');
// Apply search filter
if (!empty($search)) {
$this->auditLogModel->groupStart()
->like('audit_logs.action', $search)
->orLike('users.username', $search)
->orLike('users.email', $search)
->orLike('audit_logs.ip_address', $search)
->groupEnd();
}
// Apply action filter
if (!empty($actionFilter)) {
$this->auditLogModel->where('audit_logs.action', $actionFilter);
}
// Apply user filter
if (!empty($userFilter)) {
$this->auditLogModel->where('audit_logs.user_id', $userFilter);
}
// Get paginated results
$perPage = 20;
$page = (int) ($this->request->getGet('page') ?? 1);
$auditLogs = $this->auditLogModel->paginate($perPage, 'default', $page);
$pager = $this->auditLogModel->pager;
$total = $pager->getTotal();
// Get unique actions for filter dropdown
$actions = $this->auditLogModel->select('action')
->distinct()
->orderBy('action', 'ASC')
->findAll();
// Get users for filter dropdown
$users = $this->userModel->select('id, username, email')
->orderBy('username', 'ASC')
->findAll();
$data = [
'title' => 'Audit Log',
'auditLogs' => $auditLogs,
'pager' => $pager,
'search' => $search,
'actionFilter' => $actionFilter,
'userFilter' => $userFilter,
'actions' => $actions,
'users' => $users,
'total' => $total,
];
return view('admin/audit-logs/index', $data);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\NewsModel;
use App\Models\UserModel;
use App\Models\AuditLogModel;
class Dashboard extends BaseController
{
protected $newsModel;
protected $userModel;
protected $auditLogModel;
public function __construct()
{
$this->newsModel = new NewsModel();
$this->userModel = new UserModel();
$this->auditLogModel = new AuditLogModel();
}
public function index()
{
// Get news statistics
$totalNews = $this->newsModel->countByStatus();
$publishedNews = $this->newsModel->countByStatus('published');
$draftNews = $this->newsModel->countByStatus('draft');
// Get pages statistics (query directly since no PageModel)
$db = \Config\Database::connect();
$totalPages = $db->table('pages')->countAllResults();
$publishedPages = $db->table('pages')->where('status', 'published')->countAllResults();
$draftPages = $db->table('pages')->where('status', 'draft')->countAllResults();
// Get users statistics
$totalUsers = $this->userModel->countAllResults();
$activeUsers = $this->userModel->where('is_active', 1)->countAllResults();
// Get recent audit logs (limit 10)
$recentAuditLogs = $this->auditLogModel->select('audit_logs.*, users.username')
->join('users', 'users.id = audit_logs.user_id', 'left')
->orderBy('audit_logs.created_at', 'DESC')
->limit(10)
->findAll();
$data = [
'title' => 'Dashboard',
'stats' => [
'news' => [
'total' => $totalNews,
'published' => $publishedNews,
'draft' => $draftNews,
],
'pages' => [
'total' => $totalPages,
'published' => $publishedPages,
'draft' => $draftPages,
],
'users' => [
'total' => $totalUsers,
'active' => $activeUsers,
],
],
'recentAuditLogs' => $recentAuditLogs,
];
return view('admin/dashboard', $data);
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\NewsModel;
use App\Models\AuditLogModel;
class News extends BaseController
{
protected $newsModel;
protected $auditLogModel;
public function __construct()
{
$this->newsModel = new NewsModel();
$this->auditLogModel = new AuditLogModel();
}
/**
* Display list of news
*/
public function index()
{
$perPage = 10;
$page = $this->request->getGet('page') ?? 1;
$status = $this->request->getGet('status');
$search = $this->request->getGet('search');
// Build query with filters
$this->newsModel->select('news.*, users.username as creator_name')
->join('users', 'users.id = news.created_by', 'left');
// Filter by status
if ($status && in_array($status, ['draft', 'published'])) {
$this->newsModel->where('news.status', $status);
}
// Search
if ($search) {
$this->newsModel->groupStart()
->like('news.title', $search)
->orLike('news.content', $search)
->groupEnd();
}
// Get paginated results
$news = $this->newsModel->orderBy('news.created_at', 'DESC')
->paginate($perPage, 'default', $page);
$pager = $this->newsModel->pager;
$data = [
'title' => 'Berita',
'news' => $news,
'pager' => $pager,
'currentStatus' => $status,
'currentSearch' => $search,
'stats' => [
'total' => $this->newsModel->countByStatus(),
'published' => $this->newsModel->countByStatus('published'),
'draft' => $this->newsModel->countByStatus('draft'),
],
];
return view('admin/news/index', $data);
}
/**
* Show form to create new news
*/
public function create()
{
$data = [
'title' => 'Tambah Berita',
'news' => null,
];
return view('admin/news/form', $data);
}
/**
* Store new news
*/
public function store()
{
$validation = \Config\Services::validation();
$rules = [
'title' => 'required|min_length[3]|max_length[255]',
'content' => 'required',
'status' => 'required|in_list[draft,published]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation);
}
$title = $this->request->getPost('title');
$slug = $this->newsModel->generateSlug($title);
$content = $this->request->getPost('content');
$contentHtml = $this->request->getPost('content_html');
$contentJson = $this->request->getPost('content_json');
$excerpt = $this->request->getPost('excerpt');
$status = $this->request->getPost('status');
$userId = session()->get('user_id');
// Use content_html if available, otherwise use content
$finalContent = !empty($contentHtml) ? $contentHtml : $content;
$data = [
'title' => $title,
'slug' => $slug,
'content' => $finalContent,
'content_html' => $contentHtml,
'content_json' => $contentJson,
'excerpt' => $excerpt,
'status' => $status,
'created_by' => $userId,
];
// Set published_at if status is published
if ($status === 'published') {
$data['published_at'] = date('Y-m-d H:i:s');
}
if ($this->newsModel->insert($data)) {
// Log action
$this->auditLogModel->logAction('news_created', $userId);
return redirect()->to('/admin/news')
->with('success', 'Berita berhasil ditambahkan.');
}
return redirect()->back()
->withInput()
->with('error', 'Gagal menambahkan berita.');
}
/**
* Show form to edit news
*/
public function edit($id)
{
$news = $this->newsModel->find($id);
if (!$news) {
return redirect()->to('/admin/news')
->with('error', 'Berita tidak ditemukan.');
}
$data = [
'title' => 'Edit Berita',
'news' => $news,
];
return view('admin/news/form', $data);
}
/**
* Update news
*/
public function update($id)
{
$news = $this->newsModel->find($id);
if (!$news) {
return redirect()->to('/admin/news')
->with('error', 'Berita tidak ditemukan.');
}
$validation = \Config\Services::validation();
$rules = [
'title' => 'required|min_length[3]|max_length[255]',
'content' => 'required',
'status' => 'required|in_list[draft,published]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation);
}
$title = $this->request->getPost('title');
$oldTitle = $news['title'];
$content = $this->request->getPost('content');
$contentHtml = $this->request->getPost('content_html');
$contentJson = $this->request->getPost('content_json');
$excerpt = $this->request->getPost('excerpt');
$status = $this->request->getPost('status');
$userId = session()->get('user_id');
// Use content_html if available, otherwise use content
$finalContent = !empty($contentHtml) ? $contentHtml : $content;
// Generate new slug if title changed
$slug = ($title !== $oldTitle)
? $this->newsModel->generateSlug($title, $id)
: $news['slug'];
$data = [
'title' => $title,
'slug' => $slug,
'content' => $finalContent,
'content_html' => $contentHtml,
'content_json' => $contentJson,
'excerpt' => $excerpt,
'status' => $status,
];
// Set published_at if status changed to published and wasn't published before
if ($status === 'published' && empty($news['published_at'])) {
$data['published_at'] = date('Y-m-d H:i:s');
}
try {
// Skip model validation karena sudah divalidasi di controller
$this->newsModel->skipValidation(true);
$result = $this->newsModel->update($id, $data);
if ($result === false) {
// Get validation errors if any
$errors = $this->newsModel->errors();
$errorMessage = !empty($errors)
? implode(', ', $errors)
: 'Gagal memperbarui berita.';
log_message('error', 'News update failed - ID: ' . $id . ', Errors: ' . json_encode($errors));
return redirect()->back()
->withInput()
->with('error', $errorMessage);
}
// Log action
$this->auditLogModel->logAction('news_updated', $userId);
return redirect()->to('/admin/news')
->with('success', 'Berita berhasil diperbarui.');
} catch (\Exception $e) {
log_message('error', 'News update exception - ID: ' . $id . ', Error: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
return redirect()->back()
->withInput()
->with('error', 'Terjadi kesalahan saat memperbarui berita: ' . $e->getMessage());
}
}
/**
* Delete news
*/
public function delete($id)
{
$news = $this->newsModel->find($id);
if (!$news) {
return redirect()->to('/admin/news')
->with('error', 'Berita tidak ditemukan.');
}
$userId = session()->get('user_id');
if ($this->newsModel->delete($id)) {
// Log action
$this->auditLogModel->logAction('news_deleted', $userId);
return redirect()->to('/admin/news')
->with('success', 'Berita berhasil dihapus.');
}
return redirect()->to('/admin/news')
->with('error', 'Gagal menghapus berita.');
}
}

View File

@@ -0,0 +1,442 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\PageModel;
use App\Models\AuditLogModel;
use App\Services\ContentRenderer;
class Pages extends BaseController
{
protected $pageModel;
protected $auditLogModel;
public function __construct()
{
$this->pageModel = new PageModel();
$this->auditLogModel = new AuditLogModel();
}
/**
* Display list of pages
*/
public function index()
{
$perPage = 10;
$page = $this->request->getGet('page') ?? 1;
$status = $this->request->getGet('status');
$search = $this->request->getGet('search');
// Build query with filters
$this->pageModel->select('pages.*');
// Filter by status
if ($status && in_array($status, ['draft', 'published'])) {
$this->pageModel->where('pages.status', $status);
}
// Search
if ($search) {
$this->pageModel->groupStart()
->like('pages.title', $search)
->orLike('pages.content_html', $search)
->orLike('pages.excerpt', $search)
->groupEnd();
}
// Get paginated results
$pages = $this->pageModel->orderBy('pages.created_at', 'DESC')
->paginate($perPage, 'default', $page);
$pager = $this->pageModel->pager;
$data = [
'title' => 'Halaman',
'pages' => $pages,
'pager' => $pager,
'currentStatus' => $status,
'currentSearch' => $search,
'stats' => [
'total' => $this->pageModel->countByStatus(),
'published' => $this->pageModel->countByStatus('published'),
'draft' => $this->pageModel->countByStatus('draft'),
],
];
return view('admin/pages/index', $data);
}
/**
* Show form to create new page
*/
public function create()
{
$data = [
'title' => 'Tambah Halaman',
'page' => null,
];
return view('admin/pages/form', $data);
}
/**
* Store new page
*/
public function store()
{
$validation = \Config\Services::validation();
$rules = [
'title' => 'required|min_length[3]|max_length[255]',
'content_json' => 'permit_empty',
'status' => 'required|in_list[draft,published]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation);
}
$title = $this->request->getPost('title');
$slug = $this->pageModel->generateSlug($title);
$contentJson = $this->request->getPost('content_json') ?? '{}';
$contentHtml = $this->request->getPost('content_html') ?? '';
$excerpt = $this->request->getPost('excerpt') ?? '';
$featuredImage = $this->request->getPost('featured_image') ?? null;
$status = $this->request->getPost('status');
$userId = session()->get('user_id');
// Validate and parse JSON
$blocks = [];
if (!empty($contentJson)) {
$parsed = json_decode($contentJson, true);
if (json_last_error() === JSON_ERROR_NONE && isset($parsed['blocks'])) {
$blocks = $parsed['blocks'];
}
}
// Render HTML from JSON if not provided
if (empty($contentHtml) && !empty($blocks)) {
$contentHtml = ContentRenderer::renderEditorJsToHtml($blocks);
}
// Sanitize HTML
$contentHtml = $this->sanitizeHtml($contentHtml);
// Extract excerpt if empty
if (empty($excerpt) && !empty($blocks)) {
$excerpt = ContentRenderer::extractExcerpt($blocks);
}
$data = [
'title' => $title,
'slug' => $slug,
'content' => $contentHtml, // Keep for backward compatibility
'content_json' => $contentJson,
'content_html' => $contentHtml,
'excerpt' => $excerpt,
'featured_image' => $featuredImage,
'status' => $status,
];
if ($this->pageModel->insert($data)) {
// Log action
$this->auditLogModel->logAction('page_created', $userId);
return redirect()->to('/admin/pages')
->with('success', 'Halaman berhasil ditambahkan.');
}
return redirect()->back()
->withInput()
->with('error', 'Gagal menambahkan halaman.');
}
/**
* Show form to edit page
*/
public function edit($id)
{
$page = $this->pageModel->find($id);
if (!$page) {
return redirect()->to('/admin/pages')
->with('error', 'Halaman tidak ditemukan.');
}
$data = [
'title' => 'Edit Halaman',
'page' => $page,
];
return view('admin/pages/form', $data);
}
/**
* Update page
*/
public function update($id)
{
$page = $this->pageModel->find($id);
if (!$page) {
return redirect()->to('/admin/pages')
->with('error', 'Halaman tidak ditemukan.');
}
$validation = \Config\Services::validation();
$rules = [
'title' => 'required|min_length[3]|max_length[255]',
'content_json' => 'permit_empty',
'status' => 'required|in_list[draft,published]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation);
}
$title = $this->request->getPost('title');
$oldTitle = $page['title'];
$contentJson = $this->request->getPost('content_json') ?? '{}';
$contentHtml = $this->request->getPost('content_html') ?? '';
$excerpt = $this->request->getPost('excerpt') ?? '';
$featuredImage = $this->request->getPost('featured_image') ?? null;
$status = $this->request->getPost('status');
$userId = session()->get('user_id');
// Validate and parse JSON
$blocks = [];
if (!empty($contentJson)) {
$parsed = json_decode($contentJson, true);
if (json_last_error() === JSON_ERROR_NONE && isset($parsed['blocks'])) {
$blocks = $parsed['blocks'];
}
}
// Render HTML from JSON if not provided
if (empty($contentHtml) && !empty($blocks)) {
$contentHtml = ContentRenderer::renderEditorJsToHtml($blocks);
}
// Sanitize HTML
$contentHtml = $this->sanitizeHtml($contentHtml);
// Extract excerpt if empty
if (empty($excerpt) && !empty($blocks)) {
$excerpt = ContentRenderer::extractExcerpt($blocks);
}
// Generate new slug if title changed
$slug = ($title !== $oldTitle)
? $this->pageModel->generateSlug($title, $id)
: $page['slug'];
$data = [
'title' => $title,
'slug' => $slug,
'content' => $contentHtml, // Keep for backward compatibility
'content_json' => $contentJson,
'content_html' => $contentHtml,
'excerpt' => $excerpt,
'featured_image' => $featuredImage,
'status' => $status,
];
try {
$this->pageModel->skipValidation(true);
$result = $this->pageModel->update($id, $data);
if ($result === false) {
$errors = $this->pageModel->errors();
$errorMessage = !empty($errors)
? implode(', ', $errors)
: 'Gagal memperbarui halaman.';
log_message('error', 'Page update failed - ID: ' . $id . ', Errors: ' . json_encode($errors));
return redirect()->back()
->withInput()
->with('error', $errorMessage);
}
// Log action
$this->auditLogModel->logAction('page_updated', $userId);
return redirect()->to('/admin/pages')
->with('success', 'Halaman berhasil diperbarui.');
} catch (\Exception $e) {
log_message('error', 'Page update exception - ID: ' . $id . ', Error: ' . $e->getMessage());
return redirect()->back()
->withInput()
->with('error', 'Terjadi kesalahan saat memperbarui halaman: ' . $e->getMessage());
}
}
/**
* Autosave page (AJAX)
*/
public function autosave($id)
{
if (!$this->request->isAJAX()) {
return $this->response->setJSON(['success' => false, 'message' => 'Invalid request']);
}
$page = $this->pageModel->find($id);
if (!$page) {
return $this->response->setJSON(['success' => false, 'message' => 'Page not found']);
}
$contentJson = $this->request->getPost('content_json') ?? '{}';
$contentHtml = $this->request->getPost('content_html') ?? '';
// Validate JSON
$blocks = [];
if (!empty($contentJson)) {
$parsed = json_decode($contentJson, true);
if (json_last_error() === JSON_ERROR_NONE && isset($parsed['blocks'])) {
$blocks = $parsed['blocks'];
}
}
// Render HTML if not provided
if (empty($contentHtml) && !empty($blocks)) {
$contentHtml = ContentRenderer::renderEditorJsToHtml($blocks);
}
// Sanitize HTML
$contentHtml = $this->sanitizeHtml($contentHtml);
// Extract excerpt
$excerpt = '';
if (!empty($blocks)) {
$excerpt = ContentRenderer::extractExcerpt($blocks);
}
$data = [
'content_json' => $contentJson,
'content_html' => $contentHtml,
'excerpt' => $excerpt,
];
try {
$this->pageModel->skipValidation(true);
$this->pageModel->update($id, $data);
return $this->response->setJSON([
'success' => true,
'message' => 'Autosaved',
'timestamp' => date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
log_message('error', 'Autosave failed - ID: ' . $id . ', Error: ' . $e->getMessage());
return $this->response->setJSON(['success' => false, 'message' => 'Autosave failed']);
}
}
/**
* Upload image (AJAX)
*/
public function upload()
{
if (!$this->request->isAJAX()) {
return $this->response->setJSON(['success' => 0, 'message' => 'Invalid request']);
}
$file = $this->request->getFile('image');
if (!$file || !$file->isValid()) {
return $this->response->setJSON(['success' => 0, 'message' => 'No file uploaded']);
}
// Validate file type
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($file->getMimeType(), $allowedTypes)) {
return $this->response->setJSON(['success' => 0, 'message' => 'Invalid file type. Only JPG, PNG, and WebP are allowed.']);
}
// Validate file size (2MB max)
if ($file->getSize() > 2 * 1024 * 1024) {
return $this->response->setJSON(['success' => 0, 'message' => 'File size exceeds 2MB limit.']);
}
// Generate random filename
$extension = $file->getExtension();
$newName = uniqid('page_', true) . '.' . $extension;
$uploadPath = WRITEPATH . 'uploads/pages/';
// Create directory if not exists
if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0755, true);
}
// Move file
if ($file->move($uploadPath, $newName)) {
$url = base_url('writable/uploads/pages/' . $newName);
return $this->response->setJSON([
'success' => 1,
'file' => [
'url' => $url,
],
]);
}
return $this->response->setJSON(['success' => 0, 'message' => 'Upload failed']);
}
/**
* Delete page
*/
public function delete($id)
{
$page = $this->pageModel->find($id);
if (!$page) {
return redirect()->to('/admin/pages')
->with('error', 'Halaman tidak ditemukan.');
}
$userId = session()->get('user_id');
if ($this->pageModel->delete($id)) {
// Log action
$this->auditLogModel->logAction('page_deleted', $userId);
return redirect()->to('/admin/pages')
->with('success', 'Halaman berhasil dihapus.');
}
return redirect()->to('/admin/pages')
->with('error', 'Gagal menghapus halaman.');
}
/**
* Sanitize HTML using basic PHP functions
* For production, consider using HTMLPurifier library
*
* @param string $html
* @return string
*/
protected function sanitizeHtml(string $html): string
{
// Basic sanitization - allow common HTML tags
$allowedTags = '<p><h1><h2><h3><h4><h5><h6><ul><ol><li><blockquote><cite><pre><code><table><tbody><tr><td><th><hr><figure><img><figcaption><a><div><strong><em><u><s><br>';
// Strip all tags except allowed
$html = strip_tags($html, $allowedTags);
// Remove dangerous attributes
$html = preg_replace('/on\w+="[^"]*"/i', '', $html);
$html = preg_replace('/javascript:/i', '', $html);
return $html;
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\UserModel;
class Profile extends BaseController
{
protected $userModel;
public function __construct()
{
$this->userModel = new UserModel();
}
/**
* Display profile edit form
*/
public function index()
{
$userId = session()->get('user_id');
if (!$userId) {
return redirect()->to('/auth/login')
->with('error', 'Silakan login terlebih dahulu.');
}
$user = $this->userModel->find($userId);
if (!$user) {
return redirect()->to('/admin/dashboard')
->with('error', 'User tidak ditemukan.');
}
$data = [
'title' => 'Edit Profile',
'user' => $user,
];
return view('admin/profile/index', $data);
}
/**
* Update profile
*/
public function update()
{
$userId = session()->get('user_id');
if (!$userId) {
return redirect()->to('/auth/login')
->with('error', 'Silakan login terlebih dahulu.');
}
$user = $this->userModel->find($userId);
if (!$user) {
return redirect()->to('/admin/dashboard')
->with('error', 'User tidak ditemukan.');
}
$validation = \Config\Services::validation();
$rules = [
'username' => 'required|min_length[3]|max_length[100]',
'email' => 'required|valid_email|max_length[255]',
'phone_number' => 'permit_empty|max_length[20]',
'telegram_id' => 'permit_empty|integer',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $validation->getErrors());
}
// Check if username is unique (except current user)
$existingUser = $this->userModel->where('username', $this->request->getPost('username'))
->where('id !=', $userId)
->first();
if ($existingUser) {
return redirect()->back()
->withInput()
->with('errors', ['username' => 'Username sudah digunakan.']);
}
// Check if email is unique (except current user)
$existingEmail = $this->userModel->where('email', $this->request->getPost('email'))
->where('id !=', $userId)
->first();
if ($existingEmail) {
return redirect()->back()
->withInput()
->with('errors', ['email' => 'Email sudah digunakan.']);
}
$data = [
'username' => $this->request->getPost('username'),
'email' => $this->request->getPost('email'),
'phone_number' => $this->request->getPost('phone_number') ?: null,
'telegram_id' => $this->request->getPost('telegram_id') ?: null,
];
// Update password if provided
$newPassword = $this->request->getPost('password');
if (!empty($newPassword)) {
if (strlen($newPassword) < 6) {
return redirect()->back()
->withInput()
->with('error', 'Password minimal 6 karakter.');
}
$data['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
}
if ($this->userModel->update($userId, $data)) {
// Update session data
session()->set([
'username' => $data['username'],
'email' => $data['email'],
]);
return redirect()->to('/admin/profile')
->with('success', 'Profile berhasil diperbarui.');
}
return redirect()->back()
->withInput()
->with('error', 'Gagal memperbarui profile.');
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\SettingsModel;
class Settings extends BaseController
{
protected $settingsModel;
public function __construct()
{
$this->settingsModel = new SettingsModel();
// Check if user is admin
if (session()->get('role') !== 'admin') {
throw new \CodeIgniter\Exceptions\PageNotFoundException();
}
}
/**
* Display settings form
*/
public function index()
{
// Get all settings
$settings = $this->settingsModel->findAll();
// Convert to key-value array for easier access
$settingsArray = [];
foreach ($settings as $setting) {
$settingsArray[$setting['key']] = $setting;
}
$data = [
'title' => 'Pengaturan',
'settings' => $settingsArray,
];
return view('admin/settings/index', $data);
}
/**
* Update settings
*/
public function update()
{
$validation = \Config\Services::validation();
$rules = [
'site_name' => 'required|min_length[3]|max_length[100]',
'site_description' => 'permit_empty|max_length[255]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $validation->getErrors());
}
$siteName = $this->request->getPost('site_name');
$siteDescription = $this->request->getPost('site_description') ?: '';
// Update or create site_name
$this->settingsModel->setSetting(
'site_name',
$siteName,
'Nama situs yang ditampilkan di sidebar dan judul halaman'
);
// Update or create site_description
$this->settingsModel->setSetting(
'site_description',
$siteDescription,
'Deskripsi singkat tentang situs'
);
return redirect()->to('/admin/settings')
->with('success', 'Pengaturan berhasil diperbarui.');
}
}

View File

@@ -0,0 +1,356 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\UserModel;
use App\Models\RoleModel;
use App\Models\AuditLogModel;
class Users extends BaseController
{
protected $userModel;
protected $roleModel;
protected $auditLogModel;
public function __construct()
{
$this->userModel = new UserModel();
$this->roleModel = new RoleModel();
$this->auditLogModel = new AuditLogModel();
// Check if user is admin
if (session()->get('role') !== 'admin') {
throw new \CodeIgniter\Exceptions\PageNotFoundException();
}
}
/**
* Display list of users
*/
public function index()
{
$perPage = 10;
$page = $this->request->getGet('page') ?? 1;
$role = $this->request->getGet('role');
$status = $this->request->getGet('status');
$search = $this->request->getGet('search');
// Build query with filters
$this->userModel->select('users.*, roles.name as role_name')
->join('roles', 'roles.id = users.role_id', 'left');
// Filter by role
if ($role) {
$this->userModel->where('roles.name', $role);
}
// Filter by status
if ($status !== null && $status !== '') {
$this->userModel->where('users.is_active', $status);
}
// Search
if ($search) {
$this->userModel->groupStart()
->like('users.username', $search)
->orLike('users.email', $search)
->orLike('users.phone_number', $search)
->groupEnd();
}
// Get paginated results
$users = $this->userModel->orderBy('users.created_at', 'DESC')
->paginate($perPage, 'default', $page);
$pager = $this->userModel->pager;
// Get roles for filter
$roles = $this->roleModel->findAll();
$data = [
'title' => 'Pengguna',
'users' => $users,
'pager' => $pager,
'roles' => $roles,
'currentRole' => $role,
'currentStatus' => $status,
'currentSearch' => $search,
'stats' => [
'total' => $this->userModel->countAllResults(),
'active' => $this->userModel->where('is_active', 1)->countAllResults(),
'inactive' => $this->userModel->where('is_active', 0)->countAllResults(),
],
];
return view('admin/users/index', $data);
}
/**
* Show form to create new user
*/
public function create()
{
$roles = $this->roleModel->findAll();
$data = [
'title' => 'Tambah Pengguna',
'user' => null,
'roles' => $roles,
];
return view('admin/users/form', $data);
}
/**
* Store new user
*/
public function store()
{
$validation = \Config\Services::validation();
$rules = [
'username' => 'required|min_length[3]|max_length[100]|is_unique[users.username]',
'email' => 'required|valid_email|max_length[255]|is_unique[users.email]',
'password' => 'required|min_length[6]',
'role_id' => 'required|integer',
'phone_number' => 'permit_empty|max_length[20]|is_unique[users.phone_number]',
'telegram_id' => 'permit_empty|integer|is_unique[users.telegram_id]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation);
}
$username = $this->request->getPost('username');
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$roleId = $this->request->getPost('role_id');
$phoneNumber = $this->request->getPost('phone_number');
$telegramId = $this->request->getPost('telegram_id');
$isActive = $this->request->getPost('is_active') ? 1 : 0;
$userId = session()->get('user_id');
$data = [
'username' => $username,
'email' => $email,
'password_hash' => $password, // Will be hashed by beforeInsert
'role_id' => $roleId,
'phone_number' => !empty($phoneNumber) ? $phoneNumber : null,
'telegram_id' => !empty($telegramId) ? $telegramId : null,
'is_active' => $isActive,
];
if ($this->userModel->insert($data)) {
// Log action
$this->auditLogModel->logAction('user_created', $userId);
return redirect()->to('/admin/users')
->with('success', 'Pengguna berhasil ditambahkan.');
}
return redirect()->back()
->withInput()
->with('error', 'Gagal menambahkan pengguna.');
}
/**
* Show form to edit user
*/
public function edit($id)
{
$user = $this->userModel->find($id);
if (!$user) {
return redirect()->to('/admin/users')
->with('error', 'Pengguna tidak ditemukan.');
}
$roles = $this->roleModel->findAll();
$data = [
'title' => 'Edit Pengguna',
'user' => $user,
'roles' => $roles,
];
return view('admin/users/form', $data);
}
/**
* Update user
*/
public function update($id)
{
$user = $this->userModel->find($id);
if (!$user) {
return redirect()->to('/admin/users')
->with('error', 'Pengguna tidak ditemukan.');
}
$validation = \Config\Services::validation();
$rules = [
'username' => "required|min_length[3]|max_length[100]|is_unique[users.username,id,{$id}]",
'email' => "required|valid_email|max_length[255]|is_unique[users.email,id,{$id}]",
'role_id' => 'required|integer',
'phone_number' => "permit_empty|max_length[20]|is_unique[users.phone_number,id,{$id}]",
'telegram_id' => "permit_empty|integer|is_unique[users.telegram_id,id,{$id}]",
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation);
}
$username = $this->request->getPost('username');
$email = $this->request->getPost('email');
$roleId = $this->request->getPost('role_id');
$phoneNumber = $this->request->getPost('phone_number');
$telegramId = $this->request->getPost('telegram_id');
$isActive = $this->request->getPost('is_active') ? 1 : 0;
$userId = session()->get('user_id');
$data = [
'username' => $username,
'email' => $email,
'role_id' => $roleId,
'phone_number' => !empty($phoneNumber) ? $phoneNumber : null,
'telegram_id' => !empty($telegramId) ? $telegramId : null,
'is_active' => $isActive,
];
if ($this->userModel->update($id, $data)) {
// Log action
$this->auditLogModel->logAction('user_updated', $userId);
return redirect()->to('/admin/users')
->with('success', 'Pengguna berhasil diperbarui.');
}
return redirect()->back()
->withInput()
->with('error', 'Gagal memperbarui pengguna.');
}
/**
* Reset user password
*/
public function resetPassword($id)
{
$user = $this->userModel->find($id);
if (!$user) {
return redirect()->to('/admin/users')
->with('error', 'Pengguna tidak ditemukan.');
}
$validation = \Config\Services::validation();
$rules = [
'new_password' => 'required|min_length[6]',
'confirm_password' => 'required|matches[new_password]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('validation', $validation)
->with('error', 'Password tidak valid atau tidak cocok.');
}
$newPassword = $this->request->getPost('new_password');
$userId = session()->get('user_id');
$data = [
'password_hash' => $newPassword, // Will be hashed by beforeUpdate
];
if ($this->userModel->update($id, $data)) {
// Log action
$this->auditLogModel->logAction('user_password_reset', $userId);
return redirect()->to('/admin/users')
->with('success', 'Password pengguna berhasil direset.');
}
return redirect()->back()
->with('error', 'Gagal mereset password.');
}
/**
* Toggle user active status
*/
public function toggleActive($id)
{
$user = $this->userModel->find($id);
if (!$user) {
return redirect()->to('/admin/users')
->with('error', 'Pengguna tidak ditemukan.');
}
// Prevent deactivating yourself
if ($id == session()->get('user_id')) {
return redirect()->to('/admin/users')
->with('error', 'Anda tidak dapat menonaktifkan akun sendiri.');
}
$newStatus = $user['is_active'] ? 0 : 1;
$userId = session()->get('user_id');
$data = [
'is_active' => $newStatus,
];
if ($this->userModel->update($id, $data)) {
// Log action
$action = $newStatus ? 'user_activated' : 'user_deactivated';
$this->auditLogModel->logAction($action, $userId);
$message = $newStatus ? 'Pengguna berhasil diaktifkan.' : 'Pengguna berhasil dinonaktifkan.';
return redirect()->to('/admin/users')
->with('success', $message);
}
return redirect()->to('/admin/users')
->with('error', 'Gagal mengubah status pengguna.');
}
/**
* Delete user
*/
public function delete($id)
{
$user = $this->userModel->find($id);
if (!$user) {
return redirect()->to('/admin/users')
->with('error', 'Pengguna tidak ditemukan.');
}
// Prevent deleting yourself
if ($id == session()->get('user_id')) {
return redirect()->to('/admin/users')
->with('error', 'Anda tidak dapat menghapus akun sendiri.');
}
$userId = session()->get('user_id');
if ($this->userModel->delete($id)) {
// Log action
$this->auditLogModel->logAction('user_deleted', $userId);
return redirect()->to('/admin/users')
->with('success', 'Pengguna berhasil dihapus.');
}
return redirect()->to('/admin/users')
->with('error', 'Gagal menghapus pengguna.');
}
}

View File

@@ -0,0 +1,382 @@
<?php
namespace App\Controllers;
use App\Models\UserModel;
use App\Models\RoleModel;
use App\Models\AuditLogModel;
use App\Models\LoginAttemptModel;
use CodeIgniter\HTTP\RedirectResponse;
class AuthController extends BaseController
{
protected $userModel;
protected $roleModel;
protected $auditLogModel;
protected $loginAttemptModel;
protected $throttler;
/**
* Konfigurasi rate limiting - BERDASARKAN FAILED ATTEMPTS SAJA
* Environment-aware: lebih longgar di development, ketat di production
*/
protected function getRateLimitConfig(): array
{
if (ENVIRONMENT === 'production') {
return [
'soft_limit' => 5, // Delay setelah 5 failed attempts
'hard_limit' => 20, // Block (429) setelah 20 failed attempts
'ttl_seconds' => 900, // 15 menit
'delay_ms' => 500, // Delay 500ms setelah soft_limit
];
} else {
// Development: lebih longgar untuk testing
return [
'soft_limit' => 20, // Delay setelah 20 failed attempts
'hard_limit' => 100, // Block setelah 100 failed attempts
'ttl_seconds' => 900, // 15 menit
'delay_ms' => 200, // Delay 200ms setelah soft_limit
];
}
}
public function __construct()
{
$this->userModel = new UserModel();
$this->roleModel = new RoleModel();
$this->auditLogModel = new AuditLogModel();
$this->loginAttemptModel = new LoginAttemptModel();
$this->throttler = \Config\Services::throttler();
}
public function login()
{
// If already logged in, redirect to admin dashboard
if (session()->get('is_logged_in')) {
return redirect()->to('/admin');
}
// Debug: Log request method
$method = $this->request->getMethod();
log_message('debug', 'Login method: ' . $method);
log_message('debug', 'Request URI: ' . $this->request->getUri()->getPath());
log_message('debug', 'Is POST? ' . ($method === 'post' ? 'YES' : 'NO'));
if (strtolower($method) === 'post') {
try {
// ============================================================
// INITIALIZE RATE LIMITING COUNTERS
// ============================================================
$ipAddress = $this->request->getIPAddress();
$cfg = $this->getRateLimitConfig();
$cache = \Config\Services::cache();
// Normalize username - handle berbagai format input (audit tool variations)
$usernameRaw = $this->request->getPost('username')
?? $this->request->getPost('email')
?? $this->request->getPost('identity')
?? '';
$usernameNormalized = strtolower(trim($usernameRaw));
if (empty($usernameNormalized)) {
$usernameNormalized = 'unknown';
}
// Dual-key counter: per IP+username dan per IP (untuk handle random usernames)
// Dual-key counter: per IP+username dan per IP (untuk handle random usernames dari audit tool)
// Gunakan underscore bukan colon untuk menghindari reserved characters {}()/\@:
$keyUser = 'login_fail_' . md5($ipAddress . '_' . $usernameNormalized);
$keyIp = 'login_fail_ip_' . md5($ipAddress);
$failUser = $cache->get($keyUser) ?? 0;
$failIp = $cache->get($keyIp) ?? 0;
$failMax = max($failUser, $failIp);
// HARD LIMIT CHECK - Block sebelum validasi (audit must see 429)
if ($failMax >= $cfg['hard_limit']) {
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, null, false);
log_message('warning', "Hard rate limit exceeded - Fail count: {$failMax} (User: {$failUser}, IP: {$failIp}) - IP: {$ipAddress}");
$response = service('response');
$response->setStatusCode(429);
$response->setHeader('Retry-After', (string) $cfg['ttl_seconds']);
$response->setHeader('X-RateLimit-Limit', (string) $cfg['hard_limit']);
$response->setHeader('X-RateLimit-Remaining', '0');
$response->setHeader('Content-Type', 'text/html; charset=utf-8');
return view('auth/login', [
'error' => 'Terlalu banyak percobaan login. Silakan coba lagi dalam ' . ($cfg['ttl_seconds'] / 60) . ' menit.',
]);
}
// ============================================================
// VALIDASI INPUT
// ============================================================
$password = $this->request->getPost('password') ?? '';
$validation = \Config\Services::validation();
$rules = [
'username' => 'required|min_length[3]|max_length[100]',
'password' => 'required|min_length[6]',
];
// Validation error = failed attempt
if (!$this->validate($rules)) {
$this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg);
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, null, false);
return view('auth/login', [
'validation' => $validation,
]);
}
if (empty($usernameRaw) || empty($password)) {
$this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg);
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, null, false);
return view('auth/login', [
'error' => 'Username dan password harus diisi.',
]);
}
// ============================================================
// CEK SOFT LIMIT SEBELUM VALIDASI PASSWORD
// ============================================================
// Jika sudah mencapai soft_limit, block SEBELUM validasi password
// Ini mencegah user dengan password benar tetap bisa login setelah banyak failed attempts
if ($failMax >= $cfg['soft_limit']) {
log_message('warning', "Soft rate limit exceeded BEFORE password check - Fail count: {$failMax} (User: {$failUser}, IP: {$failIp}) - IP: {$ipAddress}, Username: {$usernameRaw}");
$response = service('response');
$response->setStatusCode(429);
$response->setHeader('Retry-After', (string) $cfg['ttl_seconds']);
$response->setHeader('X-RateLimit-Limit', (string) $cfg['soft_limit']);
$response->setHeader('X-RateLimit-Remaining', '0');
$response->setHeader('Content-Type', 'text/html; charset=utf-8');
return view('auth/login', [
'error' => 'Terlalu banyak percobaan login yang gagal. Silakan coba lagi dalam ' . ($cfg['ttl_seconds'] / 60) . ' menit.',
]);
}
// ============================================================
// VERIFIKASI USER DAN PASSWORD
// ============================================================
$user = $this->userModel->getUserByUsername($usernameRaw);
$passwordValid = false;
if ($user) {
$passwordValid = $this->userModel->verifyPassword($password, $user['password_hash']);
}
// User not found atau password salah = failed attempt
if (!$user || !$passwordValid) {
$this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg);
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'] ?? null, false);
// Get updated fail count setelah increment
$failUser = $cache->get($keyUser) ?? 0;
$failIp = $cache->get($keyIp) ?? 0;
$failMax = max($failUser, $failIp);
// Cek lagi setelah increment - jika sudah mencapai soft_limit, block
if ($failMax >= $cfg['soft_limit']) {
log_message('warning', "Soft rate limit exceeded AFTER increment - Fail count: {$failMax} (User: {$failUser}, IP: {$failIp}) - IP: {$ipAddress}, Username: {$usernameRaw}");
$response = service('response');
$response->setStatusCode(429);
$response->setHeader('Retry-After', (string) $cfg['ttl_seconds']);
$response->setHeader('X-RateLimit-Limit', (string) $cfg['soft_limit']);
$response->setHeader('X-RateLimit-Remaining', '0');
$response->setHeader('Content-Type', 'text/html; charset=utf-8');
return view('auth/login', [
'error' => 'Terlalu banyak percobaan login yang gagal. Silakan coba lagi dalam ' . ($cfg['ttl_seconds'] / 60) . ' menit.',
]);
}
log_message('info', "Login failed - IP: {$ipAddress}, Username: {$usernameRaw}, Fail count: {$failMax}");
return view('auth/login', [
'error' => 'Username atau password salah.',
]);
}
// Check if user is active
if (!$user['is_active']) {
$this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg);
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'], false);
// Apply soft limit delay
$failUser = $cache->get($keyUser) ?? 0;
$failIp = $cache->get($keyIp) ?? 0;
$failMax = max($failUser, $failIp);
if ($failMax >= $cfg['soft_limit']) {
usleep($cfg['delay_ms'] * 1000);
}
return view('auth/login', [
'error' => 'Akun Anda telah dinonaktifkan.',
]);
}
// ============================================================
// VERIFIKASI ROLE
// ============================================================
$role = $this->roleModel->find($user['role_id']);
$roleName = $role ? $role['name'] : 'editor';
// Check if role is admin or editor
if (!in_array($roleName, ['admin', 'editor'])) {
$this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg);
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'], false);
// Apply soft limit delay
$failUser = $cache->get($keyUser) ?? 0;
$failIp = $cache->get($keyIp) ?? 0;
$failMax = max($failUser, $failIp);
if ($failMax >= $cfg['soft_limit']) {
usleep($cfg['delay_ms'] * 1000);
}
return view('auth/login', [
'error' => 'Anda tidak memiliki akses ke sistem ini.',
]);
}
// ============================================================
// SESSION MANAGEMENT - Mencegah Session Fixation Attack
// ============================================================
// URUTAN PENTING: Set session data DULU, baru regenerate
// Ini memastikan session ID berubah setelah privilege escalation
$session = session();
// Set session data TERLEBIH DAHULU
$session->set([
'is_logged_in' => true,
'user_id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
'role' => $roleName,
'role_id' => $user['role_id'],
]);
// Dapatkan session ID sebelum regenerate (untuk logging)
$oldSessionId = session_id();
// Regenerate session ID SETELAH set session data
// Parameter true = destroy old session data untuk keamanan maksimal
$session->regenerate(true);
// Dapatkan session ID baru setelah regenerate
$newSessionId = session_id();
log_message('info', "Session regenerated after login - Old: {$oldSessionId}, New: {$newSessionId}");
// Verifikasi session ID benar-benar berubah
if ($oldSessionId === $newSessionId) {
log_message('warning', "Session ID tidak berubah setelah regenerate! Memaksa regenerate lagi...");
$session->regenerate(true);
$newSessionId = session_id();
log_message('info', "Session ID setelah regenerate kedua: {$newSessionId}");
}
// ============================================================
// RECORD SUCCESSFUL LOGIN & RESET FAILED ATTEMPTS
// ============================================================
// Record successful login attempt
$this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'], true);
// Reset failed attempts counter karena login berhasil
// Password benar = reset fail count (boleh bypass soft limit)
$cache->delete($keyUser);
$cache->delete($keyIp);
log_message('info', "Login successful - Failed attempts counter reset for IP: {$ipAddress}, Username: {$usernameRaw}");
// Update last login
$this->userModel->update($user['id'], [
'last_login_at' => date('Y-m-d H:i:s'),
]);
// Log login action ke audit log
$this->auditLogModel->logAction('login', $user['id']);
log_message('info', "Login successful - User: {$user['username']} (ID: {$user['id']}) from IP: {$ipAddress}");
// Optional: Send Telegram notification if telegram_id exists
if (!empty($user['telegram_id'])) {
$this->sendTelegramNotification($user['telegram_id'], $user['username']);
}
return redirect()->to('/admin')->with('success', 'Selamat datang, ' . $user['username'] . '!');
} catch (\Exception $e) {
log_message('error', 'Login error: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
return view('auth/login', [
'error' => 'Terjadi kesalahan saat login: ' . $e->getMessage(),
]);
}
}
return view('auth/login');
}
public function logout(): RedirectResponse
{
$userId = session()->get('user_id');
// Log logout action before destroying session
if ($userId) {
try {
$this->auditLogModel->logAction('logout', $userId);
} catch (\Exception $e) {
log_message('error', 'Logout audit log failed: ' . $e->getMessage());
// Continue with logout even if audit log fails
}
}
// Destroy session
session()->destroy();
return redirect()->to('/auth/login')->with('success', 'Anda telah berhasil logout.');
}
/**
* Increment failed attempts counter (dual-key: IP+username dan IP)
*
* @param \CodeIgniter\Cache\CacheInterface $cache
* @param string $keyUser Cache key untuk IP+username
* @param string $keyIp Cache key untuk IP
* @param array $cfg Rate limit configuration
*/
protected function incrementFailedAttempts($cache, string $keyUser, string $keyIp, array $cfg): void
{
$failUser = $cache->get($keyUser) ?? 0;
$failIp = $cache->get($keyIp) ?? 0;
$failUser++;
$failIp++;
$cache->save($keyUser, $failUser, $cfg['ttl_seconds']);
$cache->save($keyIp, $failIp, $cfg['ttl_seconds']);
}
/**
* Optional: Send Telegram notification on login
*/
protected function sendTelegramNotification($telegramId, $username)
{
// This is optional - implement if you have Telegram bot configured
// Example implementation:
// $botToken = getenv('TELEGRAM_BOT_TOKEN');
// if ($botToken) {
// $message = "Login berhasil untuk user: {$username}";
// $url = "https://api.telegram.org/bot{$botToken}/sendMessage";
// $data = ['chat_id' => $telegramId, 'text' => $message];
// // Use HTTP client to send request
// }
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* BaseController provides a convenient place for loading components
* and performing functions that are needed by all your controllers.
*
* Extend this class in any new controllers:
* ```
* class Home extends BaseController
* ```
*
* For security, be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Be sure to declare properties for any property fetch you initialized.
* The creation of dynamic property is deprecated in PHP 8.2.
*/
// protected $session;
/**
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
// $this->helpers = ['form', 'url'];
// Caution: Do not edit this line.
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
// $this->session = service('session');
}
}

11
app/Controllers/Home.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App\Controllers;
class Home extends BaseController
{
public function index(): string
{
return view('welcome_message');
}
}

View File

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateRolesTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 50,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('name');
$this->forge->createTable('roles', true, ['ENGINE' => 'InnoDB']);
}
public function down()
{
$this->forge->dropTable('roles');
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateUsersTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'role_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'username' => [
'type' => 'VARCHAR',
'constraint' => 100,
],
'email' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'phone_number' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => true,
],
'password_hash' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'telegram_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'null' => true,
],
'is_active' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1,
],
'last_login_at' => [
'type' => 'DATETIME',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('username');
$this->forge->addUniqueKey('email');
$this->forge->addUniqueKey('phone_number');
$this->forge->addUniqueKey('telegram_id');
$this->forge->addKey('role_id');
$this->forge->addForeignKey('role_id', 'roles', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('users', true, ['ENGINE' => 'InnoDB']);
}
public function down()
{
$this->forge->dropTable('users');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateNewsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'title' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'slug' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'content' => [
'type' => 'LONGTEXT',
],
'status' => [
'type' => 'ENUM',
'constraint' => ['draft', 'published'],
'default' => 'draft',
],
'published_at' => [
'type' => 'DATETIME',
'null' => true,
],
'created_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('slug');
$this->forge->addKey('created_by');
$this->forge->addKey('status');
$this->forge->addForeignKey('created_by', 'users', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('news', true, ['ENGINE' => 'InnoDB']);
}
public function down()
{
$this->forge->dropTable('news');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreatePagesTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'title' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'slug' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'content' => [
'type' => 'LONGTEXT',
],
'status' => [
'type' => 'ENUM',
'constraint' => ['draft', 'published'],
'default' => 'draft',
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('slug');
$this->forge->addKey('status');
$this->forge->createTable('pages', true, ['ENGINE' => 'InnoDB']);
}
public function down()
{
$this->forge->dropTable('pages');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAuditLogsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'user_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'action' => [
'type' => 'VARCHAR',
'constraint' => 100,
],
'ip_address' => [
'type' => 'VARCHAR',
'constraint' => 45,
],
'user_agent' => [
'type' => 'TEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('user_id');
$this->forge->addKey('action');
$this->forge->addKey('created_at');
$this->forge->addForeignKey('user_id', 'users', 'id', 'SET NULL', 'CASCADE');
$this->forge->createTable('audit_logs', true, ['ENGINE' => 'InnoDB']);
}
public function down()
{
$this->forge->dropTable('audit_logs');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateSettingsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'key' => [
'type' => 'VARCHAR',
'constraint' => '100',
],
'value' => [
'type' => 'TEXT',
'null' => true,
],
'description' => [
'type' => 'VARCHAR',
'constraint' => '255',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('key');
$this->forge->createTable('settings', true);
}
public function down()
{
$this->forge->dropTable('settings', true);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Migration untuk membuat tabel login_attempts
*
* Tabel ini digunakan untuk:
* - Mencatat semua percobaan login (berhasil dan gagal)
* - Monitoring aktivitas mencurigakan
* - Implementasi account lockout
* - Audit trail untuk keamanan
*/
class CreateLoginAttemptsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'ip_address' => [
'type' => 'VARCHAR',
'constraint' => '45',
'comment' => 'IP address dari percobaan login',
],
'username' => [
'type' => 'VARCHAR',
'constraint' => '100',
'null' => true,
'comment' => 'Username yang dicoba (null jika user tidak ditemukan)',
],
'user_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
'comment' => 'ID user jika login berhasil',
],
'success' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'comment' => '1 = berhasil, 0 = gagal',
],
'user_agent' => [
'type' => 'TEXT',
'null' => true,
'comment' => 'User agent browser',
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('ip_address');
$this->forge->addKey('username');
$this->forge->addKey('user_id');
$this->forge->addKey('created_at');
// Index composite untuk query cepat berdasarkan IP dan waktu
$this->forge->addKey(['ip_address', 'created_at']);
// Index composite untuk query berdasarkan username dan waktu
$this->forge->addKey(['username', 'created_at']);
$this->forge->createTable('login_attempts', true);
}
public function down()
{
$this->forge->dropTable('login_attempts', true);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UpdatePagesTableForEditorJs extends Migration
{
public function up()
{
$fields = [
'content_json' => [
'type' => 'LONGTEXT',
'null' => true,
'after' => 'content',
],
'content_html' => [
'type' => 'LONGTEXT',
'null' => true,
'after' => 'content_json',
],
'excerpt' => [
'type' => 'TEXT',
'null' => true,
'after' => 'content_html',
],
'featured_image' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'after' => 'excerpt',
],
];
$this->forge->addColumn('pages', $fields);
}
public function down()
{
$this->forge->dropColumn('pages', ['content_json', 'content_html', 'excerpt', 'featured_image']);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UpdateNewsTableForEditorJs extends Migration
{
public function up()
{
$fields = [
'content_json' => [
'type' => 'LONGTEXT',
'null' => true,
'after' => 'content',
],
'content_html' => [
'type' => 'LONGTEXT',
'null' => true,
'after' => 'content_json',
],
'excerpt' => [
'type' => 'TEXT',
'null' => true,
'after' => 'content_html',
],
];
$this->forge->addColumn('news', $fields);
}
public function down()
{
$this->forge->dropColumn('news', ['content_json', 'content_html', 'excerpt']);
}
}

View File

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class AdminSeeder extends Seeder
{
public function run(): void
{
$db = \Config\Database::connect();
echo "Starting AdminSeeder...\n";
// Step 1: Check and create admin role if not exists
$roleBuilder = $db->table('roles');
$adminRole = $roleBuilder->where('name', 'admin')->get()->getRowArray();
if (!$adminRole) {
$roleData = [
'name' => 'admin',
];
$roleBuilder->insert($roleData);
$adminRoleId = $db->insertID();
echo "✓ Role 'admin' created successfully (ID: {$adminRoleId})\n";
} else {
$adminRoleId = $adminRole['id'];
echo "✓ Role 'admin' already exists (ID: {$adminRoleId})\n";
}
// Step 2: Check and create editor role if not exists (optional, but good practice)
$editorRole = $roleBuilder->where('name', 'editor')->get()->getRowArray();
if (!$editorRole) {
$roleData = [
'name' => 'editor',
];
$roleBuilder->insert($roleData);
echo "✓ Role 'editor' created successfully\n";
} else {
echo "✓ Role 'editor' already exists\n";
}
// Step 3: Check if admin user already exists
$userBuilder = $db->table('users');
$adminUser = $userBuilder->where('username', 'admin')->get()->getRowArray();
if (!$adminUser) {
// Create admin user
$userData = [
'role_id' => $adminRoleId,
'username' => 'admin',
'email' => 'admin@bapenda.local',
'phone_number' => '081234567890',
'password_hash' => password_hash('Admin@123', PASSWORD_DEFAULT),
'telegram_id' => null,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$userBuilder->insert($userData);
$userId = $db->insertID();
echo "✓ Admin user created successfully (ID: {$userId})\n";
echo " Username: admin\n";
echo " Email: admin@bapenda.local\n";
echo " Password: Admin@123\n";
} else {
// Update password to ensure it's correct (idempotent)
$passwordHash = password_hash('Admin@123', PASSWORD_DEFAULT);
$userBuilder->where('id', $adminUser['id'])->update([
'password_hash' => $passwordHash,
'role_id' => $adminRoleId,
'is_active' => 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
echo "✓ Admin user already exists (ID: {$adminUser['id']})\n";
echo " Username: {$adminUser['username']}\n";
echo " Email: {$adminUser['email']}\n";
echo " Password: Admin@123 (updated)\n";
}
echo "\nAdminSeeder completed successfully!\n";
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class RolesSeeder extends Seeder
{
public function run()
{
//
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use App\Models\SettingsModel;
class SettingsSeeder extends Seeder
{
public function run()
{
$settingsModel = new SettingsModel();
echo "Starting SettingsSeeder...\n";
// Default settings
$defaultSettings = [
[
'key' => 'site_name',
'value' => 'Bapenda Garut',
'description' => 'Nama situs yang ditampilkan di sidebar dan judul halaman',
],
[
'key' => 'site_description',
'value' => 'Badan Pendapatan Daerah Kabupaten Garut',
'description' => 'Deskripsi singkat tentang situs',
],
];
foreach ($defaultSettings as $setting) {
$existing = $settingsModel->where('key', $setting['key'])->first();
if (!$existing) {
$settingsModel->insert($setting);
echo "✓ Setting '{$setting['key']}' created successfully\n";
} else {
echo "✓ Setting '{$setting['key']}' already exists\n";
}
}
echo "\nSettingsSeeder completed successfully!\n";
}
}

0
app/Filters/.gitkeep Normal file
View File

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class AuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// Check if user is logged in
if (!session()->get('is_logged_in')) {
return redirect()->to('/auth/login');
}
// Check if user role is admin or editor
$userRole = session()->get('role');
if (!in_array($userRole, ['admin', 'editor'])) {
session()->destroy();
return redirect()->to('/auth/login')->with('error', 'Anda tidak memiliki akses ke sistem ini.');
}
// If role arguments are provided, check user role
if ($arguments !== null && !empty($arguments)) {
if (!in_array($userRole, $arguments)) {
return redirect()->to('/admin')->with('error', 'Anda tidak memiliki akses ke halaman ini.');
}
}
return $request;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Do nothing
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class SecurityHeaders implements FilterInterface
{
/**
* Do whatever processing this filter needs to do.
* By default it should not return anything during
* normal execution. However, when an abnormal state
* is found, it should return an instance of
* CodeIgniter\HTTP\Response. If it does, script
* execution will end and that Response will be
* sent back to the client, allowing for error pages,
* redirects, etc.
*
* @param RequestInterface $request
* @param array|null $arguments
*
* @return mixed
*/
public function before(RequestInterface $request, $arguments = null)
{
// No action needed before request
}
/**
* Allows After filters to inspect and modify the response
* object as needed. This method does not allow any way
* to stop execution of other after filters, short of
* throwing an Exception or Error.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @param array|null $arguments
*
* @return mixed
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
$isProduction = ENVIRONMENT === 'production';
// ============================================================
// BASIC SECURITY HEADERS
// ============================================================
// X-Frame-Options: Mencegah clickjacking attacks
// SAMEORIGIN = hanya allow framing dari same origin
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
// X-Content-Type-Options: Mencegah MIME type sniffing
// nosniff = browser tidak boleh menebak content type
$response->setHeader('X-Content-Type-Options', 'nosniff');
// X-XSS-Protection: Legacy header untuk browser lama (optional)
// Mode=block = block page jika XSS terdeteksi
$response->setHeader('X-XSS-Protection', '1; mode=block');
// Referrer-Policy: Kontrol informasi referrer yang dikirim
// strict-origin-when-cross-origin = kirim full URL untuk same-origin,
// hanya origin untuk cross-origin HTTPS, tidak ada untuk HTTP
$response->setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions-Policy: Kontrol fitur browser yang bisa digunakan
// Membatasi akses ke fitur seperti geolocation, camera, microphone, dll
// Hanya gunakan feature yang didukung oleh browser modern
$permissionsPolicy = [
'geolocation=()', // Geolocation API
'camera=()', // Camera access
'microphone=()', // Microphone access
'payment=()', // Payment Request API
'usb=()', // WebUSB API
'magnetometer=()', // Magnetometer sensor
'gyroscope=()', // Gyroscope sensor
'accelerometer=()', // Accelerometer sensor
'ambient-light-sensor=()', // Ambient light sensor
'autoplay=()', // Autoplay media
'fullscreen=()', // Fullscreen API
'picture-in-picture=()', // Picture-in-picture
];
$response->setHeader('Permissions-Policy', implode(', ', $permissionsPolicy));
// ============================================================
// HSTS (HTTP Strict Transport Security)
// ============================================================
// Hanya aktif di production dengan HTTPS
// max-age=31536000 = 1 tahun
// includeSubDomains = berlaku untuk semua subdomain
// preload = bisa ditambahkan ke HSTS preload list (optional)
if ($isProduction) {
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// ============================================================
// CONTENT SECURITY POLICY (CSP)
// ============================================================
// CSP directives untuk mencegah XSS attacks
// Konfigurasi disesuaikan untuk TailAdmin dan Alpine.js
//
// CATATAN: Alpine.js memerlukan 'unsafe-eval' untuk mengevaluasi
// expression JavaScript (x-data, x-show, dll). Ini trade-off security
// yang diperlukan untuk Alpine.js bekerja dengan baik.
$cspDirectives = [
"default-src 'self'", // Default: hanya dari same origin
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net", // Script: allow inline dan eval untuk Alpine.js
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", // Style: allow inline untuk Tailwind
"font-src 'self' data: https://fonts.gstatic.com", // Font: allow data URI dan Google Fonts
"img-src 'self' data: https:", // Image: allow data URI dan HTTPS
"connect-src 'self'", // AJAX/Fetch: hanya same origin
"frame-ancestors 'self'", // Frame: hanya same origin
"base-uri 'self'", // Base URI: hanya same origin
"form-action 'self'", // Form action: hanya same origin
"object-src 'none'", // Object/embed: tidak ada
];
// Hanya tambahkan upgrade-insecure-requests di production
if ($isProduction) {
$cspDirectives[] = "upgrade-insecure-requests";
}
$cspValue = implode('; ', $cspDirectives);
if ($isProduction) {
// Enforce CSP di production
$response->setHeader('Content-Security-Policy', $cspValue);
} else {
// Report-Only di development untuk testing
$response->setHeader('Content-Security-Policy-Report-Only', $cspValue);
// Juga set regular CSP untuk security audit tools
$response->setHeader('Content-Security-Policy', $cspValue);
}
// ============================================================
// ADDITIONAL SECURITY HEADERS
// ============================================================
// Cross-Origin-Embedder-Policy: Mencegah embedding dari cross-origin
// require-corp = require Cross-Origin Resource Policy
// $response->setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); // Optional, bisa break beberapa fitur
// Cross-Origin-Opener-Policy: Isolasi browsing context
// same-origin = hanya same-origin yang bisa access window
// $response->setHeader('Cross-Origin-Opener-Policy', 'same-origin'); // Optional
// Cross-Origin-Resource-Policy: Kontrol resource sharing
// same-origin = hanya same-origin yang bisa load resource
// $response->setHeader('Cross-Origin-Resource-Policy', 'same-origin'); // Optional
return $response;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class ThrottleFilter implements FilterInterface
{
/**
* Rate limit configuration
* Production: 10 requests per minute
* Development: 30 requests per minute (still active for security testing)
*/
protected function getRateLimit(): int
{
return ENVIRONMENT === 'production' ? 10 : 30;
}
protected function getWindowSeconds(): int
{
return 60; // 1 minute window
}
/**
* Do whatever processing this filter needs to do.
*/
public function before(RequestInterface $request, $arguments = null)
{
// Only throttle POST requests to login
if (strtolower($request->getMethod()) !== 'post') {
return;
}
$ipAddress = $request->getIPAddress();
$cache = \Config\Services::cache();
// Use a more specific key for login endpoint
$path = $request->getUri()->getPath();
$key = 'throttle_login_' . md5($ipAddress . '_' . $path);
$current = $cache->get($key);
// Log for debugging
log_message('debug', "Throttle check - IP: {$ipAddress}, Path: {$path}, Current: " . ($current ?? 'null') . ", Limit: {$this->getRateLimit()}");
if ($current === null) {
// First request - initialize counter
$cache->save($key, 1, $this->getWindowSeconds());
log_message('debug', "Throttle initialized for IP: {$ipAddress}");
return;
}
// Increment counter
$newCount = $current + 1;
$cache->save($key, $newCount, $this->getWindowSeconds());
log_message('debug', "Throttle incremented - IP: {$ipAddress}, Count: {$newCount}, Limit: {$this->getRateLimit()}");
// Check if limit exceeded (use >= instead of > to be more strict)
if ($newCount >= $this->getRateLimit()) {
log_message('warning', "Rate limit exceeded for IP: {$ipAddress} on path: {$path} - Count: {$newCount}, Limit: {$this->getRateLimit()}");
// Create response with 429 status
$response = service('response');
$response->setStatusCode(429);
$response->setBody('Too Many Requests. Please try again later.');
$response->setHeader('Retry-After', (string) $this->getWindowSeconds());
$response->setHeader('X-RateLimit-Limit', (string) $this->getRateLimit());
$response->setHeader('X-RateLimit-Remaining', '0');
$response->setHeader('X-RateLimit-Reset', (string) (time() + $this->getWindowSeconds()));
$response->setHeader('Content-Type', 'text/plain; charset=utf-8');
$response->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
return $response;
}
// Set rate limit headers for successful requests
$remaining = max(0, $this->getRateLimit() - $newCount);
$response = service('response');
$response->setHeader('X-RateLimit-Limit', (string) $this->getRateLimit());
$response->setHeader('X-RateLimit-Remaining', (string) $remaining);
$response->setHeader('X-RateLimit-Reset', (string) (time() + $this->getWindowSeconds()));
}
/**
* Allows After filters to inspect and modify the response
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// No action needed after request
}
}

0
app/Helpers/.gitkeep Normal file
View File

0
app/Language/.gitkeep Normal file
View File

View File

@@ -0,0 +1,4 @@
<?php
// override core en language system validation or define your own en language validation message
return [];

0
app/Libraries/.gitkeep Normal file
View File

0
app/Models/.gitkeep Normal file
View File

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AuditLogModel extends Model
{
protected $table = 'audit_logs';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['user_id', 'action', 'ip_address', 'user_agent', 'created_at'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = false;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = null;
protected $deletedField = null;
public function logAction(string $action, ?int $userId = null): bool
{
$request = service('request');
$data = [
'user_id' => $userId,
'action' => $action,
'ip_address' => $request->getIPAddress(),
'user_agent' => $request->getUserAgent()->getAgentString(),
'created_at' => date('Y-m-d H:i:s'),
];
try {
return $this->insert($data);
} catch (\Exception $e) {
log_message('error', 'Audit log insert failed: ' . $e->getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* Model untuk Login Attempts
*
* Digunakan untuk:
* - Mencatat semua percobaan login
* - Menghitung jumlah percobaan gagal
* - Implementasi account lockout
* - Monitoring dan audit trail
*/
class LoginAttemptModel extends Model
{
protected $table = 'login_attempts';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['ip_address', 'username', 'user_id', 'success', 'user_agent', 'created_at'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = null;
protected $deletedField = null;
protected $validationRules = [];
protected $validationMessages = [];
protected $skipValidation = true; // Skip validation karena data dari sistem
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Mencatat percobaan login
*
* @param string $ipAddress IP address dari request
* @param string|null $username Username yang dicoba
* @param int|null $userId ID user jika login berhasil
* @param bool $success Status login (true = berhasil, false = gagal)
* @return bool True jika berhasil disimpan
*/
public function recordAttempt(string $ipAddress, ?string $username = null, ?int $userId = null, bool $success = false): bool
{
$request = service('request');
$data = [
'ip_address' => $ipAddress,
'username' => $username,
'user_id' => $userId,
'success' => $success ? 1 : 0,
'user_agent' => $request->getUserAgent()->getAgentString(),
];
try {
return $this->insert($data) !== false;
} catch (\Exception $e) {
log_message('error', 'Failed to record login attempt: ' . $e->getMessage());
return false;
}
}
/**
* Menghitung jumlah percobaan gagal dalam periode tertentu
*
* @param string $ipAddress IP address
* @param int $minutes Periode waktu dalam menit (default: 15)
* @return int Jumlah percobaan gagal
*/
public function countFailedAttempts(string $ipAddress, int $minutes = 15): int
{
$timeLimit = date('Y-m-d H:i:s', strtotime("-{$minutes} minutes"));
return $this->where('ip_address', $ipAddress)
->where('success', 0)
->where('created_at >=', $timeLimit)
->countAllResults(false);
}
/**
* Menghitung jumlah percobaan gagal untuk username tertentu
*
* @param string $username Username
* @param int $minutes Periode waktu dalam menit (default: 15)
* @return int Jumlah percobaan gagal
*/
public function countFailedAttemptsByUsername(string $username, int $minutes = 15): int
{
$timeLimit = date('Y-m-d H:i:s', strtotime("-{$minutes} minutes"));
return $this->where('username', $username)
->where('success', 0)
->where('created_at >=', $timeLimit)
->countAllResults(false);
}
/**
* Menghapus record percobaan lama (cleanup)
*
* @param int $days Jumlah hari untuk menyimpan data (default: 30)
* @return int Jumlah record yang dihapus
*/
public function cleanupOldAttempts(int $days = 30): int
{
$timeLimit = date('Y-m-d H:i:s', strtotime("-{$days} days"));
return $this->where('created_at <', $timeLimit)->delete();
}
/**
* Mendapatkan semua percobaan login untuk IP tertentu
*
* @param string $ipAddress IP address
* @param int $limit Jumlah record yang diambil
* @return array Array of login attempts
*/
public function getAttemptsByIp(string $ipAddress, int $limit = 50): array
{
return $this->where('ip_address', $ipAddress)
->orderBy('created_at', 'DESC')
->findAll($limit);
}
/**
* Mendapatkan semua percobaan login untuk username tertentu
*
* @param string $username Username
* @param int $limit Jumlah record yang diambil
* @return array Array of login attempts
*/
public function getAttemptsByUsername(string $username, int $limit = 50): array
{
return $this->where('username', $username)
->orderBy('created_at', 'DESC')
->findAll($limit);
}
}

125
app/Models/NewsModel.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class NewsModel extends Model
{
protected $table = 'news';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['title', 'slug', 'content', 'content_json', 'content_html', 'excerpt', 'status', 'published_at', 'created_by'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = null;
// Validation
protected $validationRules = [
'title' => 'required|min_length[3]|max_length[255]',
'slug' => 'required|max_length[255]|is_unique[news.slug,id,{id}]',
'content' => 'required',
'status' => 'required|in_list[draft,published]',
];
protected $validationMessages = [
'title' => [
'required' => 'Judul berita harus diisi.',
'min_length' => 'Judul berita minimal 3 karakter.',
'max_length' => 'Judul berita maksimal 255 karakter.',
],
'slug' => [
'required' => 'Slug harus diisi.',
'is_unique' => 'Slug sudah digunakan, silakan gunakan judul yang berbeda.',
],
'content' => [
'required' => 'Konten berita harus diisi.',
],
'status' => [
'required' => 'Status harus dipilih.',
'in_list' => 'Status harus draft atau published.',
],
];
protected $skipValidation = false;
protected $cleanValidationRules = true;
/**
* Generate slug from title
*/
public function generateSlug(string $title, ?int $excludeId = null): string
{
// Convert to lowercase and replace spaces with hyphens
$slug = strtolower(trim($title));
$slug = preg_replace('/[^a-z0-9-]/', '-', $slug);
$slug = preg_replace('/-+/', '-', $slug);
$slug = trim($slug, '-');
// If slug is empty, use timestamp
if (empty($slug)) {
$slug = 'news-' . time();
}
// Check if slug exists
$baseSlug = $slug;
$counter = 1;
while ($this->slugExists($slug, $excludeId)) {
$slug = $baseSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Check if slug exists
*/
protected function slugExists(string $slug, ?int $excludeId = null): bool
{
$builder = $this->where('slug', $slug);
if ($excludeId !== null) {
$builder->where('id !=', $excludeId);
}
return $builder->countAllResults() > 0;
}
/**
* Get news with creator information
*/
public function getNewsWithCreator(int $limit = 10, int $offset = 0, ?string $status = null)
{
$builder = $this->select('news.*, users.username as creator_name')
->join('users', 'users.id = news.created_by', 'left');
if ($status !== null) {
$builder->where('news.status', $status);
}
return $builder->orderBy('news.created_at', 'DESC')
->limit($limit, $offset)
->findAll();
}
/**
* Count news by status
*/
public function countByStatus(?string $status = null): int
{
if ($status !== null) {
return $this->where('status', $status)->countAllResults();
}
return $this->countAllResults();
}
}

109
app/Models/PageModel.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PageModel extends Model
{
protected $table = 'pages';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['title', 'slug', 'content', 'content_json', 'content_html', 'excerpt', 'featured_image', 'status'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = null;
// Validation
protected $validationRules = [
'title' => 'required|min_length[3]|max_length[255]',
'slug' => 'permit_empty|max_length[255]|is_unique[pages.slug,id,{id}]',
'content_json' => 'permit_empty',
'content_html' => 'permit_empty',
'status' => 'required|in_list[draft,published]',
];
protected $validationMessages = [
'title' => [
'required' => 'Judul halaman harus diisi.',
'min_length' => 'Judul halaman minimal 3 karakter.',
'max_length' => 'Judul halaman maksimal 255 karakter.',
],
'slug' => [
'required' => 'Slug harus diisi.',
'is_unique' => 'Slug sudah digunakan, silakan gunakan judul yang berbeda.',
],
'content' => [
'required' => 'Konten halaman harus diisi.',
],
'status' => [
'required' => 'Status harus dipilih.',
'in_list' => 'Status harus draft atau published.',
],
];
protected $skipValidation = false;
protected $cleanValidationRules = true;
/**
* Generate slug from title
*/
public function generateSlug(string $title, ?int $excludeId = null): string
{
// Convert to lowercase and replace spaces with hyphens
$slug = strtolower(trim($title));
$slug = preg_replace('/[^a-z0-9-]/', '-', $slug);
$slug = preg_replace('/-+/', '-', $slug);
$slug = trim($slug, '-');
// If slug is empty, use timestamp
if (empty($slug)) {
$slug = 'page-' . time();
}
// Check if slug exists
$baseSlug = $slug;
$counter = 1;
while ($this->slugExists($slug, $excludeId)) {
$slug = $baseSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Check if slug exists
*/
protected function slugExists(string $slug, ?int $excludeId = null): bool
{
$builder = $this->where('slug', $slug);
if ($excludeId !== null) {
$builder->where('id !=', $excludeId);
}
return $builder->countAllResults() > 0;
}
/**
* Count pages by status
*/
public function countByStatus(?string $status = null): int
{
if ($status !== null) {
return $this->where('status', $status)->countAllResults();
}
return $this->countAllResults();
}
}

29
app/Models/RoleModel.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class RoleModel extends Model
{
protected $table = 'roles';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['name'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = false;
protected $validationRules = [
'name' => 'required|max_length[50]|is_unique[roles.name,id,{id}]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class SettingsModel extends Model
{
protected $table = 'settings';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['key', 'value', 'description'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = null;
/**
* Get setting value by key
*/
public function getSetting(string $key, ?string $default = null): ?string
{
$setting = $this->where('key', $key)->first();
return $setting ? $setting['value'] : $default;
}
/**
* Set setting value by key
*/
public function setSetting(string $key, ?string $value, ?string $description = null): bool
{
$setting = $this->where('key', $key)->first();
if ($setting) {
return $this->update($setting['id'], [
'value' => $value,
'description' => $description ?? $setting['description'],
]);
} else {
return $this->insert([
'key' => $key,
'value' => $value,
'description' => $description,
]);
}
}
/**
* Get all settings as key-value array
*/
public function getAllSettings(): array
{
$settings = $this->findAll();
$result = [];
foreach ($settings as $setting) {
$result[$setting['key']] = $setting['value'];
}
return $result;
}
}

66
app/Models/UserModel.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class UserModel extends Model
{
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['role_id', 'username', 'email', 'phone_number', 'password_hash', 'telegram_id', 'is_active', 'last_login_at'];
protected bool $allowEmptyInserts = false;
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = null;
protected $validationRules = [
'username' => 'required|max_length[100]|is_unique[users.username,id,{id}]',
'email' => 'required|valid_email|max_length[255]|is_unique[users.email,id,{id}]',
'phone_number' => 'permit_empty|max_length[20]|is_unique[users.phone_number,id,{id}]',
'telegram_id' => 'permit_empty|integer|is_unique[users.telegram_id,id,{id}]',
'role_id' => 'required|integer',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
protected $beforeInsert = ['hashPassword'];
protected $beforeUpdate = ['hashPassword'];
protected function hashPassword(array $data)
{
if (isset($data['data']['password_hash']) && !empty($data['data']['password_hash'])) {
// Only hash if it's not already hashed (check if it starts with $2y$ which is bcrypt)
if (!preg_match('/^\$2[ayb]\$.{56}$/', $data['data']['password_hash'])) {
$data['data']['password_hash'] = password_hash($data['data']['password_hash'], PASSWORD_DEFAULT);
}
}
return $data;
}
public function getUserByUsername(string $username)
{
return $this->where('username', $username)->first();
}
public function getUserByEmail(string $email)
{
return $this->where('email', $email)->first();
}
public function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Services;
/**
* Content Renderer Service
* Converts Editor.js JSON blocks to HTML
*/
class ContentRenderer
{
/**
* Render Editor.js blocks to HTML
*
* @param array $blocks Editor.js blocks array
* @return string HTML content
*/
public static function renderEditorJsToHtml(array $blocks): string
{
$html = '';
foreach ($blocks as $block) {
if (!isset($block['type']) || !isset($block['data'])) {
continue; // Skip invalid blocks
}
switch ($block['type']) {
case 'paragraph':
$html .= '<p>' . self::escapeHtml($block['data']['text'] ?? '') . '</p>';
break;
case 'header':
$level = $block['data']['level'] ?? 2;
$text = self::escapeHtml($block['data']['text'] ?? '');
$html .= "<h{$level}>{$text}</h{$level}>";
break;
case 'list':
$style = $block['data']['style'] ?? 'unordered';
$listTag = ($style === 'ordered') ? 'ol' : 'ul';
$html .= "<{$listTag}>";
if (isset($block['data']['items']) && is_array($block['data']['items'])) {
foreach ($block['data']['items'] as $item) {
$html .= '<li>' . self::escapeHtml($item) . '</li>';
}
}
$html .= "</{$listTag}>";
break;
case 'quote':
$text = self::escapeHtml($block['data']['text'] ?? '');
$caption = isset($block['data']['caption']) ? self::escapeHtml($block['data']['caption']) : '';
$html .= '<blockquote><p>' . $text . '</p>';
if ($caption) {
$html .= '<cite>' . $caption . '</cite>';
}
$html .= '</blockquote>';
break;
case 'code':
$code = self::escapeHtml($block['data']['code'] ?? '');
$html .= '<pre><code>' . $code . '</code></pre>';
break;
case 'table':
$html .= '<table><tbody>';
if (isset($block['data']['content']) && is_array($block['data']['content'])) {
foreach ($block['data']['content'] as $row) {
if (is_array($row)) {
$html .= '<tr>';
foreach ($row as $cell) {
$html .= '<td>' . self::escapeHtml($cell) . '</td>';
}
$html .= '</tr>';
}
}
}
$html .= '</tbody></table>';
break;
case 'delimiter':
$html .= '<hr>';
break;
case 'image':
$url = self::escapeHtml($block['data']['file']['url'] ?? '');
$caption = isset($block['data']['caption']) ? self::escapeHtml($block['data']['caption']) : '';
if ($url) {
$html .= '<figure><img src="' . $url . '" alt="' . $caption . '">';
if ($caption) {
$html .= '<figcaption>' . $caption . '</figcaption>';
}
$html .= '</figure>';
}
break;
case 'linkTool':
$link = self::escapeHtml($block['data']['link'] ?? '');
$title = isset($block['data']['meta']['title'])
? self::escapeHtml($block['data']['meta']['title'])
: $link;
if ($link) {
$html .= '<div class="link-tool"><a href="' . $link . '" target="_blank" rel="noopener">' . $title . '</a></div>';
}
break;
default:
// Unknown block type - ignore safely
log_message('debug', 'Unknown Editor.js block type: ' . ($block['type'] ?? 'unknown'));
break;
}
}
return $html;
}
/**
* Escape HTML special characters
*
* @param string $text
* @return string
*/
protected static function escapeHtml(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
/**
* Extract excerpt from blocks (first paragraph)
*
* @param array $blocks
* @param int $length
* @return string
*/
public static function extractExcerpt(array $blocks, int $length = 160): string
{
foreach ($blocks as $block) {
if ($block['type'] === 'paragraph' && isset($block['data']['text'])) {
$text = strip_tags($block['data']['text']);
return mb_substr($text, 0, $length);
}
}
return '';
}
}

0
app/ThirdParty/.gitkeep vendored Normal file
View File

View File

@@ -0,0 +1,203 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Page Header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white/90">
Audit Log
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Riwayat aktivitas sistem
</p>
</div>
</div>
<!-- Stats Card -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-1">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Log</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= number_format($total) ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-brand-100 dark:bg-brand-900/20 flex items-center justify-center">
<i class="fe fe-clipboard text-brand-600 dark:text-brand-400 text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<form method="get" action="<?= base_url('admin/audit-logs') ?>" class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="flex-1">
<input
type="text"
name="search"
value="<?= esc($search ?? '') ?>"
placeholder="Cari aksi, user, atau IP address..."
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
<div>
<select
name="action"
class="h-11 rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
>
<option value="">Semua Aksi</option>
<?php foreach ($actions as $action): ?>
<option value="<?= esc($action['action']) ?>" <?= ($actionFilter === $action['action']) ? 'selected' : '' ?>>
<?= esc($action['action']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<select
name="user"
class="h-11 rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
>
<option value="">Semua User</option>
<?php foreach ($users as $user): ?>
<option value="<?= esc($user['id']) ?>" <?= ($userFilter == $user['id']) ? 'selected' : '' ?>>
<?= esc($user['username']) ?> (<?= esc($user['email']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-search"></i>
Cari
</button>
<?php if (!empty($search) || !empty($actionFilter) || !empty($userFilter)): ?>
<a
href="<?= base_url('admin/audit-logs') ?>"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
<i class="fe fe-x"></i>
Reset
</a>
<?php endif; ?>
</form>
</div>
<!-- Audit Logs Table -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="max-w-full overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-800">
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Waktu
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
User
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Aksi
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
IP Address
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
User Agent
</p>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<?php if (empty($auditLogs)): ?>
<tr>
<td colspan="5" class="px-5 py-8 text-center sm:px-6">
<p class="text-gray-500 dark:text-gray-400">Tidak ada log ditemukan.</p>
</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= date('d M Y H:i:s', strtotime($log['created_at'])) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-800 text-sm dark:text-white/90">
<?= esc($log['username'] ?? 'System') ?>
</p>
<?php if (!empty($log['email'])): ?>
<p class="ml-2 text-xs text-gray-500 dark:text-gray-400">
(<?= esc($log['email']) ?>)
</p>
<?php endif; ?>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-500/15 dark:text-brand-500">
<?= esc($log['action']) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= esc($log['ip_address']) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-xs dark:text-gray-400 max-w-xs truncate" title="<?= esc($log['user_agent']) ?>">
<?= esc($log['user_agent']) ?>
</p>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($pager->hasMore() || $pager->getCurrentPage() > 1): ?>
<div class="flex items-center justify-between border-t border-gray-100 px-5 py-4 dark:border-gray-800 sm:px-6">
<div class="text-sm text-gray-500 dark:text-gray-400">
Menampilkan <?= count($auditLogs) ?> dari <?= $pager->getTotal() ?> log
</div>
<div class="flex items-center gap-2">
<?= $pager->links() ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?= $this->endSection() ?>

View File

@@ -0,0 +1,179 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Welcome Card -->
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-2">
Selamat Datang, <?= esc(session()->get('username') ?? 'User') ?>!
</h2>
<p class="text-gray-600 dark:text-gray-400">
Ini adalah dashboard admin Bapenda Garut. Gunakan menu di sidebar untuk navigasi.
</p>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6 lg:grid-cols-4">
<!-- Total News Card -->
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-brand-100 dark:bg-brand-900/20">
<i class="fe fe-file-text text-brand-600 dark:text-brand-400 text-xl"></i>
</div>
<div class="mt-5 flex items-end justify-between">
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Total Berita</span>
<h4 class="mt-2 text-title-sm font-bold text-gray-800 dark:text-white/90">
<?= number_format($stats['news']['total']) ?>
</h4>
<div class="mt-2 flex gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>Published: <?= $stats['news']['published'] ?></span>
<span>•</span>
<span>Draft: <?= $stats['news']['draft'] ?></span>
</div>
</div>
</div>
</div>
<!-- Total Pages Card -->
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-success-100 dark:bg-success-900/20">
<i class="fe fe-file text-success-600 dark:text-success-400 text-xl"></i>
</div>
<div class="mt-5 flex items-end justify-between">
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Total Halaman</span>
<h4 class="mt-2 text-title-sm font-bold text-gray-800 dark:text-white/90">
<?= number_format($stats['pages']['total']) ?>
</h4>
<div class="mt-2 flex gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>Published: <?= $stats['pages']['published'] ?></span>
<span>•</span>
<span>Draft: <?= $stats['pages']['draft'] ?></span>
</div>
</div>
</div>
</div>
<!-- Total Users Card -->
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/20">
<i class="fe fe-users text-purple-600 dark:text-purple-400 text-xl"></i>
</div>
<div class="mt-5 flex items-end justify-between">
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Total Pengguna</span>
<h4 class="mt-2 text-title-sm font-bold text-gray-800 dark:text-white/90">
<?= number_format($stats['users']['total']) ?>
</h4>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<span>Aktif: <?= $stats['users']['active'] ?></span>
</div>
</div>
</div>
</div>
<!-- Published News Card -->
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-100 dark:bg-warning-900/20">
<i class="fe fe-check-circle text-warning-600 dark:text-warning-400 text-xl"></i>
</div>
<div class="mt-5 flex items-end justify-between">
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Berita Published</span>
<h4 class="mt-2 text-title-sm font-bold text-gray-800 dark:text-white/90">
<?= number_format($stats['news']['published']) ?>
</h4>
</div>
</div>
</div>
</div>
<!-- Recent Activity Table -->
<div class="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="px-5 py-4 sm:px-6 sm:py-5">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white/90">
Aktivitas Terbaru
</h3>
</div>
<div class="max-w-full overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-800">
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Waktu
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
User
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Aksi
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
IP Address
</p>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<?php if (empty($recentAuditLogs)): ?>
<tr>
<td colspan="4" class="px-5 py-8 text-center sm:px-6">
<p class="text-gray-500 dark:text-gray-400">Tidak ada aktivitas terbaru.</p>
</td>
</tr>
<?php else: ?>
<?php foreach ($recentAuditLogs as $log): ?>
<tr>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= date('d M Y H:i', strtotime($log['created_at'])) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-800 text-sm dark:text-white/90">
<?= esc($log['username'] ?? 'System') ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-500/15 dark:text-brand-500">
<?= esc($log['action']) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= esc($log['ip_address']) ?>
</p>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?= $this->endSection() ?>

151
app/Views/admin/layout.php Normal file
View File

@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="csrf-token" content="<?= csrf_hash() ?>" />
<meta name="csrf-header" content="<?= csrf_header() ?>" />
<title><?= esc($title ?? 'Admin Dashboard') ?> - Bapenda Garut</title>
<link rel="icon" type="image/png" href="<?= base_url('assets/images/favicon_1762970389090.png') ?>" />
<link rel="shortcut icon" type="image/png" href="<?= base_url('assets/images/favicon_1762970389090.png') ?>" />
<link rel="stylesheet" href="<?= base_url('assets/css/app.css') ?>">
<style>
/* Fix Editor.js toolbar z-index to stay below header */
.ce-toolbar,
.ce-inline-toolbar,
.ce-popover,
.ce-conversion-toolbar,
.ce-settings,
.ce-block-settings,
.ce-toolbar__plus,
.ce-toolbar__settings-btn,
.ce-popover__item,
.ce-popover__items,
.ce-settings__button,
.ce-toolbar__content,
.ce-toolbar__actions {
z-index: 10 !important;
}
header,
header[class*="sticky"],
header[class*="fixed"],
header.sticky,
header.fixed {
z-index: 99999 !important;
position: relative;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
// Apply dark mode immediately if stored (before Alpine loads)
(function() {
const darkMode = JSON.parse(localStorage.getItem('darkMode') || 'false');
if (darkMode) {
document.documentElement.classList.add('dark');
}
})();
// Initialize Alpine store for dark mode
document.addEventListener('alpine:init', () => {
Alpine.store('darkMode', {
enabled: JSON.parse(localStorage.getItem('darkMode') || 'false'),
toggle() {
this.enabled = !this.enabled;
localStorage.setItem('darkMode', JSON.stringify(this.enabled));
if (this.enabled) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
});
});
</script>
</head>
<body
x-data="{ sidebarToggle: false }"
:class="{'bg-gray-900': $store.darkMode.enabled}"
>
<!-- ===== Page Wrapper Start ===== -->
<div class="flex h-screen overflow-hidden">
<!-- ===== Sidebar Start ===== -->
<?= $this->include('admin/partials/sidebar') ?>
<!-- ===== Sidebar End ===== -->
<!-- ===== Content Area Start ===== -->
<div class="relative flex flex-col flex-1 overflow-x-hidden overflow-y-auto">
<!-- ===== Header Start ===== -->
<?= $this->include('admin/partials/navbar') ?>
<!-- ===== Header End ===== -->
<!-- ===== Main Content Start ===== -->
<main>
<div class="p-4 mx-auto max-w-7xl md:p-6">
<?php if (session()->getFlashdata('success')): ?>
<div class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-sm text-green-800"><?= esc(session()->getFlashdata('success')) ?></p>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800"><?= esc(session()->getFlashdata('error')) ?></p>
</div>
<?php endif; ?>
<?= $this->renderSection('content') ?>
</div>
</main>
<!-- ===== Main Content End ===== -->
</div>
<!-- ===== Content Area End ===== -->
</div>
<!-- ===== Page Wrapper End ===== -->
<script src="<?= base_url('assets/js/app.js') ?>"></script>
<script>
// CSRF Helper for AJAX/Fetch requests
function withCsrf(options = {}) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="csrf-header"]')?.getAttribute('content');
if (!csrfToken || !csrfHeader) {
console.warn('CSRF token not found');
return options;
}
// Merge headers
options.headers = {
...options.headers,
[csrfHeader]: csrfToken,
};
return options;
}
// Override fetch to automatically include CSRF token
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
// Only add CSRF for same-origin POST/PUT/DELETE requests
if (typeof url === 'string' && (url.startsWith('/') || url.startsWith(window.location.origin))) {
const method = (options.method || 'GET').toUpperCase();
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
options = withCsrf(options);
}
}
return originalFetch(url, options);
};
// Update CSRF token in meta tags after form submission
document.addEventListener('DOMContentLoaded', function() {
// Listen for form submissions and update CSRF token from response
document.addEventListener('submit', function(e) {
// After form submit, the new CSRF token will be in the response
// We'll update it when the page reloads or via AJAX response
});
});
</script>
<?= $this->renderSection('scripts') ?>
</body>
</html>

View File

@@ -0,0 +1,170 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Page Header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white/90">
<?= $news ? 'Edit Berita' : 'Tambah Berita' ?>
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<?= $news ? 'Ubah informasi berita' : 'Tambahkan berita baru' ?>
</p>
</div>
<a
href="<?= base_url('admin/news') ?>"
class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
<i class="fe fe-arrow-left"></i>
Kembali
</a>
</div>
<!-- Flash Messages sudah ditangani di layout.php -->
<!-- Form -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="p-5 sm:p-6">
<form
action="<?= $news ? base_url('admin/news/update/' . $news['id']) : base_url('admin/news/store') ?>"
method="post"
class="space-y-6"
>
<?= csrf_field() ?>
<!-- Title -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Judul <span class="text-error-500">*</span>
</label>
<input
type="text"
name="title"
value="<?= old('title', $news['title'] ?? '') ?>"
placeholder="Masukkan judul berita"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
required
/>
<?php if (isset($validation) && $validation->hasError('title')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('title')) ?></p>
<?php endif; ?>
</div>
<!-- Editor.js Container -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Konten <span class="text-error-500">*</span>
</label>
<div id="editorjs" class="min-h-[300px] rounded-lg border border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"></div>
<!-- Hidden inputs for Editor.js data -->
<input type="hidden" name="content" id="content" value="<?= esc($news['content'] ?? '') ?>">
<input type="hidden" name="content_json" id="content_json" value="<?= esc($news['content_json'] ?? '') ?>">
<input type="hidden" name="content_html" id="content_html" value="<?= esc($news['content_html'] ?? '') ?>">
<input type="hidden" name="excerpt" id="excerpt" value="<?= esc($news['excerpt'] ?? '') ?>">
<?php if (isset($validation) && $validation->hasError('content')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('content')) ?></p>
<?php endif; ?>
<?php if (isset($validation) && $validation->hasError('content_json')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('content_json')) ?></p>
<?php endif; ?>
</div>
<!-- Status -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Status <span class="text-error-500">*</span>
</label>
<select
name="status"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
required
>
<option value="">Pilih Status</option>
<option value="draft" <?= old('status', $news['status'] ?? '') === 'draft' ? 'selected' : '' ?>>
Draft
</option>
<option value="published" <?= old('status', $news['status'] ?? '') === 'published' ? 'selected' : '' ?>>
Published
</option>
</select>
<?php if (isset($validation) && $validation->hasError('status')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('status')) ?></p>
<?php endif; ?>
</div>
<!-- Form Actions -->
<div class="flex items-center gap-3 border-t border-gray-100 pt-6 dark:border-gray-800">
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-save"></i>
<?= $news ? 'Simpan Perubahan' : 'Simpan Berita' ?>
</button>
<a
href="<?= base_url('admin/news') ?>"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
Batal
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Editor.js Bundle (Built by Vite) -->
<?php
// Get manifest file to load hashed assets
$manifestPath = FCPATH . 'assets/editor/.vite/manifest.json';
$editorJsPath = base_url('assets/editor/editor.js'); // Fallback
if (file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents($manifestPath), true);
if (isset($manifest['resources/js/editor/editor.js'])) {
$editorJsPath = base_url('assets/editor/' . $manifest['resources/js/editor/editor.js']['file']);
}
}
?>
<script src="<?= $editorJsPath ?>"></script>
<script>
// CSRF & Endpoints for Editor.js
window.csrfTokenName = '<?= csrf_token() ?>';
window.csrfTokenValue = '<?= csrf_hash() ?>';
window.csrfHeaderName = '<?= csrf_header() ?>';
window.uploadEndpoint = '<?= base_url('admin/upload') ?>';
window.linkPreviewEndpoint = '<?= base_url('admin/link-preview') ?>';
window.newsId = <?= $news ? $news['id'] : 'null' ?>;
// Fix Editor.js toolbar z-index to stay below header
document.addEventListener('DOMContentLoaded', function() {
const style = document.createElement('style');
style.textContent = `
.ce-toolbar,
.ce-inline-toolbar,
.ce-popover,
.ce-conversion-toolbar,
.ce-settings,
.ce-block-settings,
.ce-toolbar__plus,
.ce-toolbar__settings-btn,
.ce-popover__item,
.ce-popover__items,
.ce-settings__button {
z-index: 10 !important;
}
header,
header[class*="sticky"],
header[class*="fixed"] {
z-index: 99999 !important;
}
`;
document.head.appendChild(style);
});
</script>
<?= $this->endSection() ?>

View File

@@ -0,0 +1,319 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Page Header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white/90">
Berita
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Kelola berita dan artikel
</p>
</div>
<a
href="<?= base_url('admin/news/create') ?>"
class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-plus"></i>
Tambah Berita
</a>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Berita</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= $stats['total'] ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-brand-100 dark:bg-brand-900/20 flex items-center justify-center">
<i class="fe fe-file-text text-brand-600 dark:text-brand-400 text-xl"></i>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Published</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= $stats['published'] ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/20 flex items-center justify-center">
<i class="fe fe-check-circle text-success-600 dark:text-success-400 text-xl"></i>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Draft</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= $stats['draft'] ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-warning-100 dark:bg-warning-900/20 flex items-center justify-center">
<i class="fe fe-edit text-warning-600 dark:text-warning-400 text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<form method="get" action="<?= base_url('admin/news') ?>" class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="flex-1">
<input
type="text"
name="search"
value="<?= esc($currentSearch ?? '') ?>"
placeholder="Cari berita..."
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
<div>
<select
name="status"
class="h-11 rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
>
<option value="">Semua Status</option>
<option value="published" <?= ($currentStatus === 'published') ? 'selected' : '' ?>>Published</option>
<option value="draft" <?= ($currentStatus === 'draft') ? 'selected' : '' ?>>Draft</option>
</select>
</div>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-search"></i>
Cari
</button>
<?php if ($currentSearch || $currentStatus): ?>
<a
href="<?= base_url('admin/news') ?>"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
<i class="fe fe-x"></i>
Reset
</a>
<?php endif; ?>
</form>
</div>
<!-- News Table -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="max-w-full overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-800">
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Judul
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Status
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Dibuat Oleh
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Tanggal Dibuat
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Aksi
</p>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<?php if (empty($news)): ?>
<tr>
<td colspan="5" class="px-5 py-8 text-center sm:px-6">
<p class="text-gray-500 dark:text-gray-400">Tidak ada berita ditemukan.</p>
</td>
</tr>
<?php else: ?>
<?php foreach ($news as $item): ?>
<tr>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<div>
<p class="font-medium text-gray-800 text-sm dark:text-white/90">
<?= esc($item['title']) ?>
</p>
<span class="text-gray-500 text-xs dark:text-gray-400">
<?= esc($item['slug']) ?>
</span>
</div>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<?php if ($item['status'] === 'published'): ?>
<p class="rounded-full bg-success-50 px-2 py-0.5 text-xs font-medium text-success-700 dark:bg-success-500/15 dark:text-success-500">
Published
</p>
<?php else: ?>
<p class="rounded-full bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-700 dark:bg-warning-500/15 dark:text-warning-400">
Draft
</p>
<?php endif; ?>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= esc($item['creator_name'] ?? 'Unknown') ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= date('d M Y', strtotime($item['created_at'])) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center gap-2">
<a
href="<?= base_url('admin/news/edit/' . $item['id']) ?>"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
title="Edit"
>
<i class="fe fe-edit text-sm"></i>
<span class="hidden sm:inline">Edit</span>
</a>
<button
type="button"
onclick="confirmDelete(<?= $item['id'] ?>)"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-error-300 bg-white px-3 py-1.5 text-sm font-medium text-error-700 shadow-theme-xs hover:bg-error-50 dark:border-error-700 dark:bg-gray-800 dark:text-error-400 dark:hover:bg-error-900/20"
title="Hapus"
>
<i class="fe fe-trash-2 text-sm"></i>
<span class="hidden sm:inline">Hapus</span>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($pager->hasMore() || $pager->getCurrentPage() > 1): ?>
<div class="flex items-center justify-between border-t border-gray-100 px-5 py-4 dark:border-gray-800 sm:px-6">
<div class="text-sm text-gray-500 dark:text-gray-400">
Menampilkan <?= count($news) ?> dari <?= $pager->getTotal() ?> berita
</div>
<div class="flex items-center gap-2">
<?= $pager->links() ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirmModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50">
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900 w-full max-w-md">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-2" id="confirmModalTitle">
Hapus Berita
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" id="confirmModalMessage">
Apakah Anda yakin ingin menghapus berita ini? Tindakan ini tidak dapat dibatalkan.
</p>
<div class="flex items-center gap-3 pt-4">
<button
type="button"
id="confirmModalButton"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-error-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-error-600"
>
Ya, Hapus
</button>
<button
type="button"
onclick="closeConfirmModal()"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
Batal
</button>
</div>
</div>
</div>
<!-- Delete Form -->
<form id="deleteForm" method="post" action="" style="display: none;">
<input type="hidden" name="<?= csrf_token() ?>" value="<?= csrf_hash() ?>" />
</form>
<script>
let confirmCallback = null;
function showConfirmModal(title, message, buttonText, buttonClass, callback) {
document.getElementById('confirmModalTitle').textContent = title;
document.getElementById('confirmModalMessage').textContent = message;
const confirmBtn = document.getElementById('confirmModalButton');
confirmBtn.textContent = buttonText;
confirmBtn.className = `inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs ${buttonClass}`;
confirmCallback = callback;
document.getElementById('confirmModal').classList.remove('hidden');
document.getElementById('confirmModal').classList.add('flex');
}
function closeConfirmModal() {
document.getElementById('confirmModal').classList.add('hidden');
document.getElementById('confirmModal').classList.remove('flex');
confirmCallback = null;
}
function confirmDelete(id) {
showConfirmModal(
'Hapus Berita',
'Apakah Anda yakin ingin menghapus berita ini? Tindakan ini tidak dapat dibatalkan.',
'Ya, Hapus',
'bg-error-500 hover:bg-error-600',
function() {
const form = document.getElementById('deleteForm');
form.action = '<?= base_url('admin/news/delete/') ?>' + id;
form.submit();
}
);
}
// Handle confirm button click
document.getElementById('confirmModalButton').addEventListener('click', function() {
if (confirmCallback) {
confirmCallback();
closeConfirmModal();
}
});
// Close modal on outside click
document.getElementById('confirmModal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeConfirmModal();
}
});
</script>
<?= $this->endSection() ?>

View File

@@ -0,0 +1,228 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Page Header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white/90">
<?= $page ? 'Edit Halaman' : 'Tambah Halaman' ?>
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<?= $page ? 'Ubah informasi halaman' : 'Tambahkan halaman baru' ?>
</p>
</div>
<div class="flex items-center gap-2">
<span id="autosave-indicator" class="hidden text-sm text-gray-500 dark:text-gray-400">Disimpan otomatis</span>
<button
type="button"
id="preview-btn"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
<i class="fe fe-eye"></i>
Preview
</button>
</div>
</div>
<!-- Form -->
<form
action="<?= $page ? base_url('admin/pages/update/' . $page['id']) : base_url('admin/pages/store') ?>"
method="post"
class="space-y-6"
id="page-form"
>
<?= csrf_field() ?>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main Content Area (2/3 width) -->
<div class="lg:col-span-2 space-y-6">
<!-- Title -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="p-5 sm:p-6">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Judul <span class="text-error-500">*</span>
</label>
<input
type="text"
name="title"
id="title"
value="<?= old('title', $page['title'] ?? '') ?>"
placeholder="Masukkan judul halaman"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
required
>
<?php if (isset($validation) && $validation->hasError('title')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('title')) ?></p>
<?php endif; ?>
</div>
</div>
<!-- Editor.js Container -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="p-5 sm:p-6">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Konten <span class="text-error-500">*</span>
</label>
<div id="editorjs" class="min-h-[500px] rounded-lg border border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"></div>
<!-- Hidden inputs for Editor.js data -->
<input type="hidden" name="content" id="content" value="<?= esc($page['content'] ?? '') ?>">
<input type="hidden" name="content_json" id="content_json" value="<?= esc($page['content_json'] ?? '') ?>">
<input type="hidden" name="content_html" id="content_html" value="<?= esc($page['content_html'] ?? '') ?>">
<input type="hidden" name="excerpt" id="excerpt" value="<?= esc($page['excerpt'] ?? '') ?>">
<?php if (isset($validation) && $validation->hasError('content_json')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('content_json')) ?></p>
<?php endif; ?>
</div>
</div>
</div>
<!-- Sidebar (1/3 width) - Document Settings -->
<div class="space-y-6">
<!-- Status -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="p-5 sm:p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Pengaturan Dokumen</h3>
<div class="space-y-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Status <span class="text-error-500">*</span>
</label>
<select
name="status"
id="status"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
required
>
<option value="draft" <?= old('status', $page['status'] ?? 'draft') === 'draft' ? 'selected' : '' ?>>Draft</option>
<option value="published" <?= old('status', $page['status'] ?? '') === 'published' ? 'selected' : '' ?>>Published</option>
</select>
<?php if (isset($validation) && $validation->hasError('status')): ?>
<p class="mt-1 text-sm text-error-600"><?= esc($validation->getError('status')) ?></p>
<?php endif; ?>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Excerpt
</label>
<textarea
name="excerpt"
id="excerpt-textarea"
rows="3"
placeholder="Ringkasan halaman (otomatis dari konten pertama)"
class="w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
><?= old('excerpt', $page['excerpt'] ?? '') ?></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Akan diisi otomatis dari paragraf pertama</p>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Featured Image URL
</label>
<input
type="url"
name="featured_image"
id="featured_image"
value="<?= old('featured_image', $page['featured_image'] ?? '') ?>"
placeholder="https://example.com/image.jpg"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
>
<?php if (!empty($page['featured_image'] ?? '')): ?>
<div class="mt-2">
<img src="<?= esc($page['featured_image']) ?>" alt="Featured" class="h-24 w-full rounded-lg object-cover">
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Form Actions - Match grid layout -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="flex items-center justify-end gap-3 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03]">
<a
href="<?= base_url('admin/pages') ?>"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
Batal
</a>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-save"></i>
<?= $page ? 'Simpan Perubahan' : 'Simpan Halaman' ?>
</button>
</div>
</div>
</div>
</form>
</div>
<!-- Editor.js Bundle (Built by Vite) -->
<?php
// Get manifest file to load hashed assets
$manifestPath = FCPATH . 'assets/editor/.vite/manifest.json';
$editorJsPath = base_url('assets/editor/editor.js'); // Fallback
if (file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents($manifestPath), true);
if (isset($manifest['resources/js/editor/editor.js'])) {
$editorJsPath = base_url('assets/editor/' . $manifest['resources/js/editor/editor.js']['file']);
}
}
?>
<script src="<?= $editorJsPath ?>"></script>
<script>
// CSRF & Endpoints for Editor.js
window.csrfTokenName = '<?= csrf_token() ?>';
window.csrfTokenValue = '<?= csrf_hash() ?>';
window.csrfHeaderName = '<?= csrf_header() ?>';
window.uploadEndpoint = '<?= base_url('admin/upload') ?>';
window.linkPreviewEndpoint = '<?= base_url('admin/link-preview') ?>';
window.pageId = <?= $page ? $page['id'] : 'null' ?>;
// Sync excerpt textarea with hidden input
const excerptTextarea = document.getElementById('excerpt-textarea');
const excerptInput = document.getElementById('excerpt');
if (excerptTextarea && excerptInput) {
excerptTextarea.addEventListener('input', function() {
excerptInput.value = this.value;
});
}
// Fix Editor.js toolbar z-index to stay below header
document.addEventListener('DOMContentLoaded', function() {
const style = document.createElement('style');
style.textContent = `
.ce-toolbar,
.ce-inline-toolbar,
.ce-popover,
.ce-conversion-toolbar,
.ce-settings,
.ce-block-settings,
.ce-toolbar__plus,
.ce-toolbar__settings-btn,
.ce-popover__item,
.ce-popover__items,
.ce-settings__button {
z-index: 10 !important;
}
header,
header[class*="sticky"],
header[class*="fixed"] {
z-index: 99999 !important;
}
`;
document.head.appendChild(style);
});
</script>
<?= $this->endSection() ?>

View File

@@ -0,0 +1,307 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Page Header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white/90">
Halaman
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Kelola halaman statis
</p>
</div>
<a
href="<?= base_url('admin/pages/create') ?>"
class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-plus"></i>
Tambah Halaman
</a>
</div>
<!-- Flash Messages sudah ditangani di layout.php -->
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Halaman</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= $stats['total'] ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-brand-100 dark:bg-brand-900/20 flex items-center justify-center">
<i class="fe fe-file text-brand-600 dark:text-brand-400 text-xl"></i>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Published</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= $stats['published'] ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/20 flex items-center justify-center">
<i class="fe fe-check-circle text-success-600 dark:text-success-400 text-xl"></i>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Draft</p>
<p class="text-2xl font-bold text-gray-800 dark:text-white"><?= $stats['draft'] ?></p>
</div>
<div class="w-12 h-12 rounded-full bg-warning-100 dark:bg-warning-900/20 flex items-center justify-center">
<i class="fe fe-edit text-warning-600 dark:text-warning-400 text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03]">
<form method="get" action="<?= base_url('admin/pages') ?>" class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="flex-1">
<input
type="text"
name="search"
value="<?= esc($currentSearch ?? '') ?>"
placeholder="Cari halaman..."
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
<div>
<select
name="status"
class="h-11 rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
>
<option value="">Semua Status</option>
<option value="published" <?= ($currentStatus === 'published') ? 'selected' : '' ?>>Published</option>
<option value="draft" <?= ($currentStatus === 'draft') ? 'selected' : '' ?>>Draft</option>
</select>
</div>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-search"></i>
Cari
</button>
<?php if ($currentSearch || $currentStatus): ?>
<a
href="<?= base_url('admin/pages') ?>"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
<i class="fe fe-x"></i>
Reset
</a>
<?php endif; ?>
</form>
</div>
<!-- Pages Table -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="max-w-full overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-800">
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Judul
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Status
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Tanggal Dibuat
</p>
</div>
</th>
<th class="px-5 py-3 sm:px-6">
<div class="flex items-center">
<p class="font-medium text-gray-500 text-xs dark:text-gray-400">
Aksi
</p>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<?php if (empty($pages)): ?>
<tr>
<td colspan="4" class="px-5 py-8 text-center sm:px-6">
<p class="text-gray-500 dark:text-gray-400">Tidak ada halaman ditemukan.</p>
</td>
</tr>
<?php else: ?>
<?php foreach ($pages as $item): ?>
<tr>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<div>
<p class="font-medium text-gray-800 text-sm dark:text-white/90">
<?= esc($item['title']) ?>
</p>
<span class="text-gray-500 text-xs dark:text-gray-400">
<?= esc($item['slug']) ?>
</span>
</div>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<?php if ($item['status'] === 'published'): ?>
<p class="rounded-full bg-success-50 px-2 py-0.5 text-xs font-medium text-success-700 dark:bg-success-500/15 dark:text-success-500">
Published
</p>
<?php else: ?>
<p class="rounded-full bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-700 dark:bg-warning-500/15 dark:text-warning-400">
Draft
</p>
<?php endif; ?>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center">
<p class="text-gray-500 text-sm dark:text-gray-400">
<?= date('d M Y', strtotime($item['created_at'])) ?>
</p>
</div>
</td>
<td class="px-5 py-4 sm:px-6">
<div class="flex items-center gap-2">
<a
href="<?= base_url('admin/pages/edit/' . $item['id']) ?>"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
title="Edit"
>
<i class="fe fe-edit text-sm"></i>
<span class="hidden sm:inline">Edit</span>
</a>
<button
type="button"
onclick="confirmDelete(<?= $item['id'] ?>)"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-error-300 bg-white px-3 py-1.5 text-sm font-medium text-error-700 shadow-theme-xs hover:bg-error-50 dark:border-error-700 dark:bg-gray-800 dark:text-error-400 dark:hover:bg-error-900/20"
title="Hapus"
>
<i class="fe fe-trash-2 text-sm"></i>
<span class="hidden sm:inline">Hapus</span>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($pager->hasMore() || $pager->getCurrentPage() > 1): ?>
<div class="flex items-center justify-between border-t border-gray-100 px-5 py-4 dark:border-gray-800 sm:px-6">
<div class="text-sm text-gray-500 dark:text-gray-400">
Menampilkan <?= count($pages) ?> dari <?= $pager->getTotal() ?> halaman
</div>
<div class="flex items-center gap-2">
<?= $pager->links() ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirmModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50">
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900 w-full max-w-md">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-2" id="confirmModalTitle">
Hapus Halaman
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" id="confirmModalMessage">
Apakah Anda yakin ingin menghapus halaman ini? Tindakan ini tidak dapat dibatalkan.
</p>
<div class="flex items-center gap-3 pt-4">
<button
type="button"
id="confirmModalButton"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-error-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-error-600"
>
Ya, Hapus
</button>
<button
type="button"
onclick="closeConfirmModal()"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
Batal
</button>
</div>
</div>
</div>
<!-- Delete Form -->
<form id="deleteForm" method="post" action="" style="display: none;">
<input type="hidden" name="<?= csrf_token() ?>" value="<?= csrf_hash() ?>" />
</form>
<script>
let confirmCallback = null;
function showConfirmModal(title, message, buttonText, buttonClass, callback) {
document.getElementById('confirmModalTitle').textContent = title;
document.getElementById('confirmModalMessage').textContent = message;
const confirmBtn = document.getElementById('confirmModalButton');
confirmBtn.textContent = buttonText;
confirmBtn.className = `inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs ${buttonClass}`;
confirmCallback = callback;
document.getElementById('confirmModal').classList.remove('hidden');
document.getElementById('confirmModal').classList.add('flex');
}
function closeConfirmModal() {
document.getElementById('confirmModal').classList.add('hidden');
document.getElementById('confirmModal').classList.remove('flex');
confirmCallback = null;
}
function confirmDelete(id) {
showConfirmModal(
'Hapus Halaman',
'Apakah Anda yakin ingin menghapus halaman ini? Tindakan ini tidak dapat dibatalkan.',
'Ya, Hapus',
'bg-error-500 hover:bg-error-600',
function() {
const form = document.getElementById('deleteForm');
form.action = '<?= base_url('admin/pages/delete/') ?>' + id;
form.submit();
}
);
}
// Handle confirm button click
document.getElementById('confirmModalButton').addEventListener('click', function() {
if (confirmCallback) {
confirmCallback();
closeConfirmModal();
}
});
// Close modal on outside click
document.getElementById('confirmModal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeConfirmModal();
}
});
</script>
<?= $this->endSection() ?>

View File

@@ -0,0 +1,111 @@
<header
x-data="{menuToggle: false}"
class="sticky top-0 z-99999 flex w-full border-gray-200 bg-white lg:border-b dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex grow flex-col items-center justify-between lg:flex-row lg:px-6">
<div class="flex w-full items-center justify-between gap-2 border-b border-gray-200 px-3 py-3 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4 dark:border-gray-800">
<!-- Hamburger Toggle BTN -->
<button
:class="sidebarToggle ? 'lg:bg-transparent dark:lg:bg-transparent bg-gray-100 dark:bg-gray-800' : ''"
class="z-99999 flex h-10 w-10 items-center justify-center rounded-lg border-gray-200 text-gray-500 lg:h-11 lg:w-11 lg:border dark:border-gray-800 dark:text-gray-400"
@click.stop="sidebarToggle = !sidebarToggle"
>
<i class="fe fe-menu hidden lg:block text-base"></i>
<i :class="sidebarToggle ? 'hidden' : 'block lg:hidden'" class="fe fe-menu block lg:hidden text-lg"></i>
<i :class="sidebarToggle ? 'block lg:hidden' : 'hidden'" class="fe fe-x block lg:hidden text-lg"></i>
</button>
<!-- Hamburger Toggle BTN -->
<!-- Page Title -->
<div class="lg:hidden">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white"><?= esc($title ?? 'Dashboard') ?></h2>
</div>
<!-- Search -->
<div class="hidden lg:block">
<form>
<div class="relative">
<span class="absolute top-1/2 left-4 -translate-y-1/2">
<i class="fe fe-search text-gray-500 dark:text-gray-400 text-lg"></i>
</span>
<input
type="text"
placeholder="Search..."
class="h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pr-4 pl-12 text-sm text-gray-800 placeholder:text-gray-400 focus:border-primary-300 focus:ring-3 focus:ring-primary-500/10 focus:outline-none xl:w-[430px] dark:border-gray-800 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30"
/>
</div>
</form>
</div>
</div>
<div :class="menuToggle ? 'flex' : 'hidden'" class="shadow-md w-full items-center justify-between gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0 lg:shadow-none">
<div class="flex items-center gap-2">
<!-- Dark Mode Toggler -->
<button
@click="$store.darkMode.toggle()"
class="relative flex h-11 w-11 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
>
<i class="fe fe-sun hidden dark:block text-lg"></i>
<i class="fe fe-moon dark:hidden text-lg"></i>
</button>
<!-- Dark Mode Toggler -->
<!-- Notification Menu Area -->
<div class="relative" x-data="{ dropdownOpen: false }" @click.outside="dropdownOpen = false">
<button
class="relative flex h-11 w-11 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
@click.prevent="dropdownOpen = !dropdownOpen"
>
<i class="fe fe-bell text-lg"></i>
</button>
</div>
<!-- Notification Menu Area -->
</div>
<!-- User Area -->
<div class="relative" x-data="{ dropdownOpen: false }" @click.outside="dropdownOpen = false">
<a
class="flex items-center text-gray-700 dark:text-gray-400"
href="#"
@click.prevent="dropdownOpen = !dropdownOpen"
>
<span class="mr-3 h-11 w-11 overflow-hidden rounded-full">
<img src="<?= base_url('assets/images/user/owner.jpg') ?>" alt="User" class="h-full w-full object-cover" />
</span>
<span class="text-sm mr-1 block font-medium dark:text-white"><?= esc(session()->get('username') ?? 'User') ?></span>
<i :class="dropdownOpen && 'rotate-180'" class="fe fe-chevron-down text-gray-500 dark:text-gray-400 text-sm transition-transform"></i>
</a>
<!-- Dropdown Start -->
<div
x-show="dropdownOpen"
class="shadow-lg absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900"
>
<div>
<span class="text-sm block font-medium text-gray-700 dark:text-gray-400">
<?= esc(session()->get('username') ?? 'User') ?>
</span>
<span class="text-xs mt-0.5 block text-gray-500 dark:text-gray-400">
<?= esc(session()->get('email') ?? 'user@example.com') ?>
</span>
</div>
<ul class="flex flex-col gap-1 border-b border-gray-200 pt-4 pb-3 dark:border-gray-800">
<li>
<a href="<?= base_url('admin/profile') ?>" class="group text-sm flex items-center gap-3 rounded-lg px-3 py-2 font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/5">
<i class="fe fe-user text-gray-500 group-hover:text-gray-700 dark:text-gray-400"></i>
Edit profile
</a>
</li>
</ul>
<a href="<?= base_url('auth/logout') ?>" class="group text-sm mt-3 flex items-center gap-3 rounded-lg px-3 py-2 font-medium text-red-600 hover:bg-gray-100 dark:text-red-400 dark:hover:bg-white/5">
<i class="fe fe-log-out text-red-500 group-hover:text-red-700 dark:text-red-400"></i>
Sign out
</a>
</div>
<!-- Dropdown End -->
</div>
<!-- User Area -->
</div>
</div>
</header>

View File

@@ -0,0 +1,177 @@
<aside
:class="sidebarToggle ? 'translate-x-0 lg:w-[90px]' : '-translate-x-full'"
class="sidebar fixed left-0 top-0 z-9999 flex h-screen w-[290px] flex-col overflow-y-hidden border-r border-gray-200 bg-white px-5 dark:border-gray-800 dark:bg-gray-900 lg:static lg:translate-x-0"
>
<!-- SIDEBAR HEADER -->
<div
:class="sidebarToggle ? 'justify-center' : 'justify-between'"
class="flex items-center gap-2 pt-8 sidebar-header pb-7"
>
<a href="<?= base_url('admin/dashboard') ?>" class="flex items-center gap-3">
<span class="logo" :class="sidebarToggle ? 'hidden' : ''">
<img class="h-10 w-auto" src="<?= base_url('assets/images/logo/b_logo_1757803697487.png') ?>" alt="Logo Bapenda Garut" />
</span>
<img
class="logo-icon h-10 w-10 object-contain"
:class="sidebarToggle ? 'lg:block' : 'hidden'"
src="<?= base_url('assets/images/logo/b_logo_1757803697487.png') ?>"
alt="Logo"
/>
<?php
// Get site name from settings
$settingsModel = new \App\Models\SettingsModel();
$siteName = $settingsModel->getSetting('site_name', 'Bapenda Garut');
?>
<span class="site-name text-2xl font-semibold text-gray-800 dark:text-white" :class="sidebarToggle ? 'lg:hidden' : ''">
<?= esc($siteName) ?>
</span>
</a>
</div>
<!-- SIDEBAR HEADER -->
<div class="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<!-- Sidebar Menu -->
<nav>
<?php
// Get current URI segment
$uri = service('uri');
$segment1 = $uri->getSegment(1) ?? '';
$segment2 = $uri->getSegment(2) ?? '';
// Determine active menu based on URI
$activeMenu = '';
if ($segment1 === 'admin') {
if (empty($segment2) || $segment2 === 'dashboard') {
$activeMenu = 'dashboard';
} elseif ($segment2 === 'news') {
$activeMenu = 'news';
} elseif ($segment2 === 'pages') {
$activeMenu = 'pages';
} elseif ($segment2 === 'users') {
$activeMenu = 'users';
} elseif ($segment2 === 'audit-logs') {
$activeMenu = 'audit-logs';
} elseif ($segment2 === 'settings') {
$activeMenu = 'settings';
}
}
// Helper function to get active class
$getActiveClass = function($menu) use ($activeMenu) {
return $activeMenu === $menu
? 'bg-primary-50 text-primary-600 dark:bg-white/5 dark:text-primary-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5';
};
?>
<!-- Menu Group -->
<div>
<h3 class="mb-4 text-xs uppercase leading-[20px] text-gray-400">
<span class="menu-group-title" :class="sidebarToggle ? 'lg:hidden' : ''">
MENU
</span>
</h3>
<ul class="flex flex-col gap-0.5 mb-6">
<!-- Menu Item Dashboard -->
<li>
<a
href="<?= base_url('admin/dashboard') ?>"
class="menu-item group flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium duration-300 <?= $getActiveClass('dashboard') ?>"
>
<span class="flex items-center justify-center w-6 h-6">
<i class="fe fe-home text-xl"></i>
</span>
<span class="menu-item-text" :class="sidebarToggle ? 'lg:hidden' : ''">
Dashboard
</span>
</a>
</li>
<!-- Menu Item Dashboard -->
<!-- Menu Item News -->
<li>
<a
href="<?= base_url('admin/news') ?>"
class="menu-item group flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium duration-300 <?= $getActiveClass('news') ?>"
>
<span class="flex items-center justify-center w-6 h-6">
<i class="fe fe-file-text text-xl"></i>
</span>
<span class="menu-item-text" :class="sidebarToggle ? 'lg:hidden' : ''">
Berita
</span>
</a>
</li>
<!-- Menu Item News -->
<!-- Menu Item Pages -->
<li>
<a
href="<?= base_url('admin/pages') ?>"
class="menu-item group flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium duration-300 <?= $getActiveClass('pages') ?>"
>
<span class="flex items-center justify-center w-6 h-6">
<i class="fe fe-file text-xl"></i>
</span>
<span class="menu-item-text" :class="sidebarToggle ? 'lg:hidden' : ''">
Halaman
</span>
</a>
</li>
<!-- Menu Item Pages -->
<?php if (session()->get('role') === 'admin'): ?>
<!-- Menu Item Users -->
<li>
<a
href="<?= base_url('admin/users') ?>"
class="menu-item group flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium duration-300 <?= $getActiveClass('users') ?>"
>
<span class="flex items-center justify-center w-6 h-6">
<i class="fe fe-users text-xl"></i>
</span>
<span class="menu-item-text" :class="sidebarToggle ? 'lg:hidden' : ''">
Pengguna
</span>
</a>
</li>
<!-- Menu Item Users -->
<!-- Menu Item Audit Logs -->
<li>
<a
href="<?= base_url('admin/audit-logs') ?>"
class="menu-item group flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium duration-300 <?= $getActiveClass('audit-logs') ?>"
>
<span class="flex items-center justify-center w-6 h-6">
<i class="fe fe-clipboard text-xl"></i>
</span>
<span class="menu-item-text" :class="sidebarToggle ? 'lg:hidden' : ''">
Audit Log
</span>
</a>
</li>
<!-- Menu Item Audit Logs -->
<!-- Menu Item Settings -->
<li>
<a
href="<?= base_url('admin/settings') ?>"
class="menu-item group flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium duration-300 <?= $getActiveClass('settings') ?>"
>
<span class="flex items-center justify-center w-6 h-6">
<i class="fe fe-settings text-xl"></i>
</span>
<span class="menu-item-text" :class="sidebarToggle ? 'lg:hidden' : ''">
Pengaturan
</span>
</a>
</li>
<!-- Menu Item Settings -->
<?php endif; ?>
</ul>
</div>
</nav>
<!-- Sidebar Menu -->
</div>
</aside>

View File

@@ -0,0 +1,155 @@
<?= $this->extend('admin/layout') ?>
<?= $this->section('content') ?>
<div class="space-y-5 sm:space-y-6">
<!-- Page Header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white/90">
Edit Profile
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Ubah informasi profile Anda
</p>
</div>
<a
href="<?= base_url('admin/dashboard') ?>"
class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
<i class="fe fe-arrow-left"></i>
Kembali
</a>
</div>
<!-- Flash Messages -->
<?php if (session()->getFlashdata('error')): ?>
<div class="rounded-lg border border-error-200 bg-error-50 p-4 dark:border-error-800 dark:bg-error-900/20">
<p class="text-sm text-error-800 dark:text-error-400">
<?= esc(session()->getFlashdata('error')) ?>
</p>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="rounded-lg border border-success-200 bg-success-50 p-4 dark:border-success-800 dark:bg-success-900/20">
<p class="text-sm text-success-800 dark:text-success-400">
<?= esc(session()->getFlashdata('success')) ?>
</p>
</div>
<?php endif; ?>
<!-- Form -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div class="p-5 sm:p-6">
<form
action="<?= base_url('admin/profile/update') ?>"
method="post"
class="space-y-6"
>
<?= csrf_field() ?>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<!-- Username -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Username <span class="text-error-500">*</span>
</label>
<input
type="text"
name="username"
value="<?= old('username', $user['username'] ?? '') ?>"
placeholder="Masukkan username"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
required
/>
<?php if (session()->getFlashdata('errors') && isset(session()->getFlashdata('errors')['username'])): ?>
<p class="mt-1 text-sm text-error-600"><?= esc(session()->getFlashdata('errors')['username']) ?></p>
<?php endif; ?>
</div>
<!-- Email -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Email <span class="text-error-500">*</span>
</label>
<input
type="email"
name="email"
value="<?= old('email', $user['email'] ?? '') ?>"
placeholder="Masukkan email"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
required
/>
<?php if (session()->getFlashdata('errors') && isset(session()->getFlashdata('errors')['email'])): ?>
<p class="mt-1 text-sm text-error-600"><?= esc(session()->getFlashdata('errors')['email']) ?></p>
<?php endif; ?>
</div>
<!-- Password (optional) -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Password Baru
</label>
<input
type="password"
name="password"
placeholder="Kosongkan jika tidak ingin mengubah password"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
minlength="6"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Minimal 6 karakter. Kosongkan jika tidak ingin mengubah password.
</p>
</div>
<!-- Phone Number -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Nomor Telepon
</label>
<input
type="text"
name="phone_number"
value="<?= old('phone_number', $user['phone_number'] ?? '') ?>"
placeholder="Masukkan nomor telepon"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
<!-- Telegram ID -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Telegram ID
</label>
<input
type="number"
name="telegram_id"
value="<?= old('telegram_id', $user['telegram_id'] ?? '') ?>"
placeholder="Masukkan Telegram ID"
class="h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center gap-3 border-t border-gray-100 pt-6 dark:border-gray-800">
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs hover:bg-brand-600"
>
<i class="fe fe-save"></i>
Simpan Perubahan
</button>
<a
href="<?= base_url('admin/dashboard') ?>"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
Batal
</a>
</div>
</form>
</div>
</div>
</div>
<?= $this->endSection() ?>

Some files were not shown because too many files have changed in this diff Show More