fkdev Adminv1.0.0

Belgeler · Kurulum

Beş dakikada yayında.

GitHub’dan indir, sunucuna yükle, istersen PHP’ye bağla. Derleme adımı yok.

01Gereksinimler

NeSürümNot
Web sunucusuApache 2.4+ ya da NginxStatik dosya sunabilen her şey yeter
PHP8.1+Yalnızca gerçek veriye bağlayacaksan
MySQL / MariaDB8.0+ / 10.6+ornekler/php/schema.sql
HTTPSZorunluOturum çerezleri Secure bayrağıyla gönderilir

02İndir

Terminal
git clone https://github.com/furkankirci/fkdev-admin.git
cd fkdev-admin

Ya da GitHub’da Code → Download ZIP. Dosyaları public_html (ya da sanal sunucunun kök klasörü) içine kopyala. .htaccess gizli dosyadır; FTP istemcinde “gizli dosyaları göster” açık olsun.

03Apache (.htaccess)

Depodaki .htaccess hazırdır: HTTPS yönlendirmesi, temiz adresler, 404 sayfası, güvenlik başlıkları, CSP, önbellek ve hassas dosyaların engellenmesi. Kendi alan adını yazman yeter.

.htaccess.htaccess
Options -Indexes -MultiViews
DirectoryIndex index.php index.html
AddDefaultCharset UTF-8
FileETag None
ServerSignature Off

ErrorDocument 400 /404.html
ErrorDocument 403 /404.html
ErrorDocument 404 /404.html
ErrorDocument 410 /404.html

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /

  RewriteCond %{HTTPS} !=on
  RewriteCond %{HTTP:X-Forwarded-Proto} !=https
  RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

  RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
  RewriteRule ^ https://%1%{REQUEST_URI} [L,R=301]

  RewriteCond %{THE_REQUEST} \s/+(.*/)?index\.html[\s?] [NC]
  RewriteRule ^(.*/)?index\.html$ /$1 [L,R=301]

  RewriteCond %{REQUEST_FILENAME} -d
  RewriteCond %{REQUEST_URI} !/$
  RewriteRule ^(.*)$ /$1/ [L,R=301]

  RewriteRule ^404\.html$ - [L]
  RewriteCond %{REQUEST_URI} !^/\.well-known/
  RewriteRule (^|/)\. - [F,L]
  RewriteRule ^ornekler(/|$) - [F,L]
</IfModule>

<FilesMatch "(\.(md|sql|log|ini|env|bak|old|orig|swp|sh|lock|dist)|^LICENSE|^composer\.json|^package\.json)$">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
</FilesMatch>
<FilesMatch "^\.">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
</FilesMatch>

<IfModule mod_mime.c>
  AddType image/svg+xml .svg
  AddType image/x-icon .ico
  AddType application/xml .xml
  AddType text/plain .txt
  AddType application/json .json
  AddCharset UTF-8 .html .css .js .xml .txt .svg .json
</IfModule>

<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml application/xml application/javascript text/javascript application/json image/svg+xml image/x-icon
</IfModule>
<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css text/xml application/xml application/javascript text/javascript application/json image/svg+xml
</IfModule>

<IfModule mod_headers.c>
  Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" env=HTTPS
  Header always set X-Content-Type-Options "nosniff"
  Header always set X-Frame-Options "DENY"
  Header always set Referrer-Policy "strict-origin-when-cross-origin"
  Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()"
  Header always set Cross-Origin-Opener-Policy "same-origin"
  Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://ui.fkdev.sbs https://code.jquery.com https://cdn.datatables.net 'sha256-CGGEJHfqC7+LiSARylTxPdCMaADvzU9UYiv/dnwTiYU='; style-src 'self' 'unsafe-inline' https://ui.fkdev.sbs https://fonts.googleapis.com https://cdn.datatables.net; img-src 'self' data: https://ui.fkdev.sbs; font-src https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests"
  Header unset X-Powered-By

  <FilesMatch "\.html$">
    Header set Cache-Control "no-cache, must-revalidate"
  </FilesMatch>
  <FilesMatch "\.php$">
    Header set Cache-Control "no-store"
  </FilesMatch>
  <FilesMatch "\.(css|js)$">
    Header set Cache-Control "public, max-age=604800, stale-while-revalidate=86400"
  </FilesMatch>
  <FilesMatch "\.(svg|png|ico)$">
    Header set Cache-Control "public, max-age=2592000"
  </FilesMatch>
  <FilesMatch "^404\.html$">
    Header set X-Robots-Tag "noindex, follow"
  </FilesMatch>
</IfModule>

<IfModule mod_php.c>
  php_flag display_errors off
  php_flag expose_php off
  php_value session.cookie_httponly 1
  php_value session.cookie_secure 1
  php_value session.use_strict_mode 1
</IfModule>

04Nginx

Nginx/etc/nginx/sites-available/admin
server {
    listen 443 ssl http2;
    server_name admin.ornek.com;
    root /var/www/fkdev-admin;
    index index.html index.php;

    location ~ /\.(?!well-known) { deny all; }
    location ^~ /ornekler/ { deny all; }
    location ~* \.(md|sql|log|ini|env|bak)$ { deny all; }

    location / { try_files $uri $uri/ =404; }
    error_page 404 /404.html;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location ~* \.(css|js|svg|woff2)$ { expires 7d; add_header Cache-Control "public"; }
}

05CDN bağlantısı

Her sayfanın <head> bölümü fkdev UI’yi sabit sürümle ve SRI imzasıyla yükler:

HTML
<link rel="stylesheet" href="https://ui.fkdev.sbs/v2.4.1/fkdev-ui.min.css" integrity="sha384-ywbvrnvLBhspdz061sb4+HVmL4bAnbhyuwKyDq0qCBVdEeLw2Le+aT+M0XhiumnM" crossorigin="anonymous">
<link rel="stylesheet" href="https://ui.fkdev.sbs/v2.4.1/fkdev-icons.css" integrity="sha384-7yUKhYWWrNB988JPR7B5hWqmWKk50ryeZ/b688YUSNn02PjgfTkhgNDnoRUX0w4V" crossorigin="anonymous">
<script src="https://ui.fkdev.sbs/v2.4.1/fkdev-ui.min.js" integrity="sha384-M00Zh34vyulzgDN4UvQcDJteSAYjql+zh1KP3B8lMfPikmAoJLDzbKnjWiFHPsvT" crossorigin="anonymous" defer></script>

Ayrıntılar için CDN belgesi.

06PHP’ye bağlama

  1. Veritabanını oluştur ve ornekler/php/schema.sql dosyasını çalıştır.
  2. ornekler/php/ klasörünü web kökünün dışına taşı (ör. /var/www/app/); config.example.php dosyasını config.php olarak kopyalayıp doldur.
  3. Statik sayfayı PHP’ye çevir: giris/index.html → giris/index.php. En üste bootstrap’ı ekle, formun içine CSRF alanını koy.
PHPgiris/index.php
<?php require '/var/www/app/bootstrap.php'; ?>
<!-- …sayfanın geri kalanı aynı… -->
<form class="fk-form" method="post" action="/api/giris.php" data-fk-validate>
  <?= csrf_field() ?>
  <!-- e-posta ve şifre alanları -->
</form>
PHPapi/giris.php (web kökünde, tek satır)
<?php
require '/var/www/app/giris.php';
PHPornekler/php/bootstrap.php
<?php
declare(strict_types=1);

$config = require __DIR__ . '/config.php';

ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');

session_name($config['app']['session_name']);
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_start();

function db(): PDO
{
    static $pdo = null;
    global $config;
    if ($pdo) {
        return $pdo;
    }
    $c = $config['db'];
    $pdo = new PDO(
        sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $c['host'], $c['name']),
        $c['user'],
        $c['pass'],
        [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ]
    );
    return $pdo;
}

function e(?string $s): string
{
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function csrf_token(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function csrf_field(): string
{
    return '<input type="hidden" name="csrf" value="' . e(csrf_token()) . '">';
}

function csrf_check(): void
{
    $sent = $_POST['csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
    if (!is_string($sent) || !hash_equals(csrf_token(), $sent)) {
        http_response_code(419);
        exit('Oturum süresi doldu. Sayfayı yenileyip tekrar dene.');
    }
}

function client_ip(): string
{
    return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}

function too_many_attempts(string $email): bool
{
    global $config;
    $stmt = db()->prepare(
        'SELECT COUNT(*) FROM login_attempts
         WHERE success = 0 AND (ip = ? OR email = ?)
           AND created_at > NOW() - INTERVAL ? MINUTE'
    );
    $stmt->execute([client_ip(), $email, (int) $config['app']['lock_minutes']]);
    return (int) $stmt->fetchColumn() >= (int) $config['app']['max_attempts'];
}

function log_attempt(string $email, bool $ok): void
{
    db()->prepare('INSERT INTO login_attempts (ip, email, success) VALUES (?, ?, ?)')
        ->execute([client_ip(), $email, $ok ? 1 : 0]);
}

function current_user(): ?array
{
    if (empty($_SESSION['uid'])) {
        return null;
    }
    $stmt = db()->prepare('SELECT id, name, email, role FROM users WHERE id = ? AND is_active = 1');
    $stmt->execute([$_SESSION['uid']]);
    return $stmt->fetch() ?: null;
}

function require_login(array $roles = []): array
{
    $user = current_user();
    if (!$user) {
        header('Location: /giris/');
        exit;
    }
    if ($roles && !in_array($user['role'], $roles, true)) {
        http_response_code(403);
        exit('Bu sayfayı görme yetkin yok.');
    }
    return $user;
}

function redirect_with(string $url, string $message, string $type = 'success'): never
{
    $_SESSION['flash'] = [$message, $type];
    header('Location: ' . $url);
    exit;
}
PHPornekler/php/giris.php
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}
csrf_check();

$email    = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
$password = (string) ($_POST['password'] ?? '');

if (too_many_attempts($email)) {
    redirect_with('/giris/', 'Çok fazla deneme yapıldı. 15 dakika sonra tekrar dene.', 'danger');
}

$stmt = db()->prepare('SELECT id, password_hash FROM users WHERE email = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$email]);
$user = $stmt->fetch();

$ok = $user && password_verify($password, $user['password_hash']);
log_attempt($email, (bool) $ok);

if (!$ok) {
    usleep(random_int(200000, 400000));
    redirect_with('/giris/', 'E-posta ya da şifre hatalı.', 'danger');
}

if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
    db()->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
        ->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}

session_regenerate_id(true);
$_SESSION['uid'] = (int) $user['id'];
unset($_SESSION['csrf']);
db()->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')->execute([$user['id']]);

redirect_with('/', 'Tekrar hoş geldin!');
PHPornekler/php/kayit.php
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}
csrf_check();

$name     = trim((string) ($_POST['name'] ?? ''));
$email    = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
$confirm  = (string) ($_POST['password_confirm'] ?? '');
$terms    = isset($_POST['terms']);

$errors = [];
if (mb_strlen($name) < 2 || mb_strlen($name) > 120) $errors[] = 'Ad 2–120 karakter olmalı.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Geçerli bir e-posta yaz.';
if (mb_strlen($password) < 10) $errors[] = 'Şifre en az 10 karakter olmalı.';
if (!hash_equals($password, $confirm)) $errors[] = 'Şifreler aynı değil.';
if (!$terms) $errors[] = 'Kullanım koşullarını onaylaman gerekiyor.';

if ($errors) {
    redirect_with('/kayit/', implode(' ', $errors), 'danger');
}

try {
    db()->prepare('INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)')
        ->execute([$name, $email, password_hash($password, PASSWORD_DEFAULT)]);
} catch (PDOException $ex) {
    if ($ex->getCode() === '23000') {
        redirect_with('/kayit/', 'Bu e-posta ile hesap açılamadı. Hesabın varsa giriş yap.', 'danger');
    }
    error_log($ex->getMessage());
    redirect_with('/kayit/', 'Şu an kayıt yapılamıyor, biraz sonra tekrar dene.', 'danger');
}

session_regenerate_id(true);
$_SESSION['uid'] = (int) db()->lastInsertId();
redirect_with('/', 'Hesabın oluşturuldu, hoş geldin!');
PHPornekler/php/cikis.php
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}
csrf_check();

$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', [
    'expires'  => time() - 3600,
    'path'     => $p['path'],
    'secure'   => $p['secure'],
    'httponly' => $p['httponly'],
    'samesite' => $p['samesite'],
]);
session_destroy();

header('Location: /giris/');
exit;

07Panel kartlarını veriye bağlama

PHPindex.php
<?php
$user = require_login(['admin', 'editor']);
$ciro = db()->query("SELECT COALESCE(SUM(total), 0) FROM orders WHERE created_at >= DATE_FORMAT(NOW(), '%Y-%m-01') AND status <> 'İade'")->fetchColumn();
$bekleyen = db()->query("SELECT COUNT(*) FROM orders WHERE status = 'Bekliyor'")->fetchColumn();
?>
<article class="ad-kpi">
  <div class="ad-kpi-top"><span class="ad-kpi-label"><span class="ad-ico"><i class="fk-i fk-i-lira" aria-hidden="true"></i></span>Ciro</span></div>
  <strong>₺<?= number_format((float) $ciro, 0, ',', '.') ?></strong>
  <small>Bu ay</small>
</article>
<p><?= e($user['name']) ?>, <?= (int) $bekleyen ?> sipariş onay bekliyor.</p>
PHPYönlendirme sonrası bildirim
<?php if (!empty($_SESSION['flash'])): [$m, $t] = $_SESSION['flash']; unset($_SESSION['flash']); ?>
  <script>addEventListener('DOMContentLoaded', () => FK.toast(<?= json_encode($m) ?>, <?= json_encode($t) ?>));</script>
<?php endif; ?>

CSP ve satır içi betikDepodaki CSP satır içi betikleri engeller (head’deki tema betiği hariç, o hash ile izinli). Bildirim için bu küçük betiği kullanacaksan ya ayrı bir .js dosyasına taşı ya da CSP’ye nonce ekle.