commit 91bb52bce40c72ebcb74b97160455e7025d4ddab Author: kayasamat Date: Fri Aug 7 17:46:14 2026 +0300 Initial commit: Key Ajans portfolio site diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5541adb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +data/ +.DS_Store +.venv/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..754994d --- /dev/null +++ b/app.py @@ -0,0 +1,73 @@ +import os + +from flask import Flask, render_template, session + +import db +import security +from auth_routes import auth_bp +from project_routes import project_bp, public_projects +from request_routes import request_bp + + +def create_app(): + app = Flask(__name__) + cfg = security.load_server_config() + + app.config.update( + SECRET_KEY=cfg["secret"], + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + # Üretimde HTTPS altında bu değeri True yapın: + SESSION_COOKIE_SECURE=False, + PERMANENT_SESSION_LIFETIME=86400, + MAX_CONTENT_LENGTH=60 * 1024 * 1024, + ) + + db.init_db() + app.teardown_appcontext(db.close_db) + app.before_request(security.before_request) + app.after_request(security.after_request) + + @app.context_processor + def inject_globals(): + return { + "csrf_token": security.ensure_csrf_token(), + "is_admin": session.get("role") == "admin", + "current_user": session.get("name"), + } + + @app.errorhandler(400) + def bad_request(e): + return render_template("error.html", code=400, message="Geçersiz istek."), 400 + + @app.errorhandler(403) + def forbidden(e): + return render_template("error.html", code=403, message="Bu sayfaya erişim yetkiniz yok."), 403 + + @app.errorhandler(404) + def not_found(e): + return render_template("error.html", code=404, message="Sayfa bulunamadı."), 404 + + @app.errorhandler(429) + def too_many(e): + return render_template("error.html", code=429, message="Çok fazla istek. Lütfen biraz bekleyin."), 429 + + @app.errorhandler(500) + def server_error(e): + return render_template("error.html", code=500, message="Beklenmeyen bir hata oluştu."), 500 + + @app.route("/") + def index(): + return render_template("index.html", projects=public_projects()) + + app.register_blueprint(auth_bp) + app.register_blueprint(project_bp) + app.register_blueprint(request_bp) + return app + + +app = create_app() + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 3000)) + app.run(host="0.0.0.0", port=port, threaded=True) \ No newline at end of file diff --git a/auth_routes.py b/auth_routes.py new file mode 100644 index 0000000..81745e7 --- /dev/null +++ b/auth_routes.py @@ -0,0 +1,127 @@ +"""Giriş / kayıt / çıkış — düz kullanıcılar ve admin'ler.""" +from flask import ( + Blueprint, + flash, + redirect, + render_template, + request, + session, + url_for, +) +from werkzeug.security import check_password_hash, generate_password_hash + +import security +from db import get_db + +auth_bp = Blueprint("auth", __name__) + +MIN_PASSWORD = 8 + + +@auth_bp.get("/giris") +def login_page(): + return render_template("login.html") + + +def _try_admin(username, password): + cfg = security.load_server_config() + for adm in cfg["admins"]: + if username.lower() == adm["username"].lower(): + if check_password_hash(adm["password_hash"], password): + return adm + return None + + +@auth_bp.post("/giris") +@security.rate_limited("auth", 10, 900) +def do_login(): + username = security.clean_text(request.form.get("username"), 64) + password = request.form.get("password", "") + + if not username or not password: + flash("Kullanıcı adı ve şifre gerekli.", "error") + return redirect(url_for("auth.login_page")) + + adm = _try_admin(username, password) + if adm is not None: + session.clear() + session["role"] = "admin" + session["admin_name"] = adm["display_name"] + session.permanent = True + flash(f"Hoş geldiniz, {adm['display_name']}.", "success") + return redirect(url_for("projects.admin_panel")) + + db = get_db() + user = db.execute( + "SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,) + ).fetchone() + if user is None or not check_password_hash(user["password_hash"], password): + flash("Kullanıcı adı veya şifre hatalı.", "error") + return redirect(url_for("auth.login_page")) + + session.clear() + session["user_id"] = user["id"] + session["name"] = user["display_name"] + session.permanent = True + flash(f"Tekrar hoş geldiniz, {user['display_name']}.", "success") + return redirect(url_for("index")) + + +@auth_bp.get("/kayit") +def kayit(): + if session.get("user_id"): + return redirect(url_for("index")) + return render_template("register.html") + + +@auth_bp.post("/kayit") +@security.rate_limited("register", 8, 3600) +def do_register(): + username = security.validate_username(request.form.get("username")) + name = security.clean_text(request.form.get("display_name"), 100) + password = request.form.get("password", "") + confirm = request.form.get("password_confirm", "") + + if not username: + flash("Kullanıcı adı 3-32 karakter olmalı (harfler, rakamlar, . _ -).", "error") + return redirect(url_for("auth.kayit")) + if not name: + flash("Ad Soyad zorunlu.", "error") + return redirect(url_for("auth.kayit")) + if len(password) < MIN_PASSWORD: + flash("Şifre en az 8 karakter olmalı.", "error") + return redirect(url_for("auth.kayit")) + if password != confirm: + flash("Şifreler eşleşmiyor.", "error") + return redirect(url_for("auth.kayit")) + + db = get_db() + exists = db.execute( + "SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", (username,) + ).fetchone() + if exists: + flash("Bu kullanıcı adı zaten kullanılıyor.", "error") + return redirect(url_for("auth.kayit")) + + db.execute( + "INSERT INTO users (username, display_name, password_hash) VALUES (?, ?, ?)", + (username, name, generate_password_hash(password, method="pbkdf2:sha256")), + ) + db.commit() + + uid = db.execute( + "SELECT id FROM users WHERE username = ? COLLATE NOCASE", (username,) + ).fetchone()["id"] + session.clear() + session["user_id"] = uid + session["name"] = name + session.permanent = True + flash("Hesabınız oluşturuldu.", "success") + return redirect(url_for("index")) + + +@auth_bp.post("/cikis") +def cikis(): + session.clear() + flash("Çıkış yapıldı.", "info") + return redirect(url_for("index")) \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..e9f3de1 --- /dev/null +++ b/db.py @@ -0,0 +1,67 @@ +import os +import sqlite3 + +from flask import g + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") +DB_PATH = os.path.join(DATA_DIR, "portfolio.db") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + link TEXT NOT NULL DEFAULT '', + stored_name TEXT, + orig_name TEXT, + mime TEXT, + size INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_name TEXT NOT NULL, + sender_email TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_requests_created ON requests(created_at DESC); +""" + + +def init_db(): + os.makedirs(DATA_DIR, exist_ok=True) + os.makedirs(UPLOAD_DIR, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.executescript(SCHEMA) + conn.commit() + conn.close() + + +def get_db(): + if "db" not in g: + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA foreign_keys = ON") + return g.db + + +def close_db(exc=None): + db = g.pop("db", None) + if db is not None: + db.close() diff --git a/project_routes.py b/project_routes.py new file mode 100644 index 0000000..1bc3c5e --- /dev/null +++ b/project_routes.py @@ -0,0 +1,153 @@ +"""Proje yönetimi: yükleme, listeleme, indirme, silme (yalnızca admin).""" +import mimetypes +import os +import re +import uuid + +from flask import ( + Blueprint, + abort, + current_app, + flash, + redirect, + render_template, + request, + session, + send_from_directory, + url_for, +) +from werkzeug.utils import secure_filename + +import security +from db import UPLOAD_DIR, get_db + +project_bp = Blueprint("projects", __name__) + +ALLOWED_EXT = { + "png", "jpg", "jpeg", "gif", "webp", + "pdf", "zip", "rar", "7z", "tar", "gz", + "txt", "md", "html", "htm", + "doc", "docx", "ppt", "pptx", "xls", "xlsx", +} +MAX_UPLOAD = 25 * 1024 * 1024 + + +def _is_admin(): + return session.get("role") == "admin" + + +@project_bp.before_request +def _guard(): + if request.path.startswith("/admin") and not _is_admin(): + abort(403) + + +@project_bp.get("/admin/projeler") +def admin_panel(): + db = get_db() + projects = db.execute( + "SELECT * FROM projects ORDER BY created_at DESC" + ).fetchall() + return render_template("admin/projects.html", projects=projects) + + +@project_bp.post("/admin/projeler") +@security.rate_limited("upload", 12, 900) +def do_upload(): + title = security.clean_text(request.form.get("title"), 150) + description = security.clean_multiline(request.form.get("description"), 5000) + tags = security.clean_text(request.form.get("tags"), 300) + link = security.validate_link(request.form.get("link")) + + if not title: + flash("Proje başlığı zorunlu.", "error") + return redirect(url_for("projects.admin_panel")) + if link is None: + flash("Bağlantı geçersiz (http/https olmalı).", "error") + return redirect(url_for("projects.admin_panel")) + + stored_name = None + orig_name = "" + mime = None + size = None + file = request.files.get("file") + if file and file.filename: + orig = secure_filename(file.filename) or "dosya" + ext = orig.rsplit(".", 1)[-1].lower() if "." in orig else "" + if ext not in ALLOWED_EXT: + flash("Bu dosya türüne izin verilmiyor.", "error") + return redirect(url_for("projects.admin_panel")) + payload = file.read(MAX_UPLOAD + 1) + if len(payload) > MAX_UPLOAD: + flash("Dosya 25 MB sınırını aşıyor.", "error") + return redirect(url_for("projects.admin_panel")) + stored_name = uuid.uuid4().hex + "." + ext + with open(os.path.join(UPLOAD_DIR, stored_name), "wb") as fh: + fh.write(payload) + orig_name = orig + mime = mimetypes.guess_type(orig)[0] or "application/octet-stream" + size = len(payload) + + db = get_db() + db.execute( + """INSERT INTO projects (title, description, tags, link, stored_name, + orig_name, mime, size) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (title, description, tags, link, stored_name, orig_name, mime, size), + ) + db.commit() + flash("Proje yüklendi.", "success") + return redirect(url_for("projects.admin_panel")) + + +@project_bp.post("/admin/projeler//sil") +def do_delete(pid): + db = get_db() + row = db.execute("SELECT * FROM projects WHERE id = ?", (pid,)).fetchone() + if row is None: + abort(404) + if row["stored_name"]: + path = os.path.join(UPLOAD_DIR, row["stored_name"]) + if os.path.isfile(path): + os.remove(path) + db.execute("DELETE FROM projects WHERE id = ?", (pid,)) + db.commit() + flash("Proje kaldırıldı.", "info") + return redirect(url_for("projects.admin_panel")) + + +# ---------------- herkese açık uçlar ---------------- + +def _get_public_project(pid): + db = get_db() + return db.execute("SELECT * FROM projects WHERE id = ?", (pid,)).fetchone() + + +@project_bp.get("/projeler//indir") +def download(pid): + row = _get_public_project(pid) + if row is None or not row["stored_name"]: + abort(404) + return send_from_directory( + UPLOAD_DIR, + row["stored_name"], + as_attachment=True, + download_name=row["orig_name"] or "proje", + ) + + +@project_bp.get("/projeler//goster") +def show_file(pid): + row = _get_public_project(pid) + if row is None or not row["stored_name"]: + abort(404) + if not (row["mime"] or "").startswith("image/"): + abort(404) + return send_from_directory(UPLOAD_DIR, row["stored_name"]) + + +def public_projects(): + db = get_db() + return db.execute( + "SELECT * FROM projects ORDER BY created_at DESC" + ).fetchall() \ No newline at end of file diff --git a/request_routes.py b/request_routes.py new file mode 100644 index 0000000..98ad993 --- /dev/null +++ b/request_routes.py @@ -0,0 +1,73 @@ +"""Kullanıcı talepleri: gönderim (herkes) + admin gelen kutusu.""" +from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for + +import security +from db import get_db + +request_bp = Blueprint("talepler", __name__) + +SUBJECT_MAX = 150 +BODY_MAX = 5000 +NAME_MAX = 100 + + +@request_bp.get("/talep") +def new_talep(): + return render_template( + "new_talep.html", + prefill_name=session.get("name", ""), + prefill_email=session.get("email", ""), + ) + + +@request_bp.post("/talep") +@security.rate_limited("talep", 6, 900) +def do_talep(): + sender_name = security.clean_text(request.form.get("sender_name"), NAME_MAX) + sender_email = security.validate_email(request.form.get("sender_email")) + subject = security.clean_text(request.form.get("subject"), SUBJECT_MAX) + body = security.clean_multiline(request.form.get("body"), BODY_MAX) + + if not sender_name: + flash("Adınızı yazmalısınız.", "error") + return redirect(url_for("talepler.new_talep")) + if sender_email is None: + flash("Geçerli bir e-posta adresi girin.", "error") + return redirect(url_for("talepler.new_talep")) + if not subject: + flash("Talep başlığı zorunlu.", "error") + return redirect(url_for("talepler.new_talep")) + if not body: + flash("Talep açıklaması boş olamaz.", "error") + return redirect(url_for("talepler.new_talep")) + + db = get_db() + db.execute( + "INSERT INTO requests (sender_name, sender_email, subject, body) VALUES (?, ?, ?, ?)", + (sender_name, sender_email, subject, body), + ) + db.commit() + flash("Talebiniz iletildi. Teşekkürler!", "success") + return redirect(url_for("index")) + + +@request_bp.get("/admin/talepler") +def admin_inbox(): + if session.get("role") != "admin": + abort(403) + db = get_db() + requests = db.execute( + "SELECT * FROM requests ORDER BY created_at DESC" + ).fetchall() + return render_template("admin/requests.html", requests=requests) + + +@request_bp.post("/admin/talepler//sil") +def admin_delete(rid): + if session.get("role") != "admin": + abort(403) + db = get_db() + db.execute("DELETE FROM requests WHERE id = ?", (rid,)) + db.commit() + flash("Talep silindi.", "info") + return redirect(url_for("talepler.admin_inbox")) \ No newline at end of file diff --git a/security.py b/security.py new file mode 100644 index 0000000..0aa3629 --- /dev/null +++ b/security.py @@ -0,0 +1,182 @@ +"""Güvenlik katmanı: admin config, CSRF, rate limit, sanitizasyon, başlıklar.""" +import hmac +import json +import os +import re +import secrets +import threading +import time + +from flask import abort, request, session + +# --------------------------------------------------------------------------- +# Admin kimlik bilgileri PROJE DIŞINDA tutulur: +# ~/.config/key-ajansi/admins.json +# (KEY_AJANSI_ADMINS ortam değişkeniyle başka bir yola taşınabilir) +# --------------------------------------------------------------------------- +DEFAULT_CONFIG_PATH = os.path.join( + os.path.expanduser("~"), ".config", "key-ajansi", "admins.json" +) + +CONFIG_PATH = os.environ.get("KEY_AJANSI_ADMINS", DEFAULT_CONFIG_PATH) + + +def load_server_config(): + if not os.path.exists(CONFIG_PATH): + raise RuntimeError( + "Admin yapılandırması bulunamadı. Önce şunu çalıştırın:\n" + " python3 setup_admins.py\n" + f"Beklenen dosya: {CONFIG_PATH}" + ) + with open(CONFIG_PATH, "r", encoding="utf-8") as fh: + data = json.load(fh) + if not data.get("secret") or not isinstance(data.get("admins"), list): + raise RuntimeError(f"{CONFIG_PATH} dosyası hatalı; setup_admins.py ile yeniden oluşturun.") + return data + + +# --------------------------------------------------------------------------- +# Rate limiting (DDoS / brute-force koruması) +# --------------------------------------------------------------------------- +_limits = {} +_lock = threading.Lock() + + +def _prune_limits(now): + if len(_limits) > 10000: + for key in list(_limits): + if now - _limits[key][0] > 3600: + del _limits[key] + + +def check_limit(scope, limit, window): + """scope başına IP'ye limit uygula; aşıldıysa False döner.""" + key = f"{scope}:{request.remote_addr}" + now = time.time() + with _lock: + _prune_limits(now) + bucket = _limits.get(key) + if bucket is None or now - bucket[0] > window: + _limits[key] = [now, 1] + return True + if bucket[1] >= limit: + return False + bucket[1] += 1 + return True + + +def rate_limited(scope, limit, window): + def deco(fn): + def wrapper(*args, **kwargs): + if not check_limit(scope, limit, window): + abort(429) + return fn(*args, **kwargs) + + wrapper.__name__ = fn.__name__ + return wrapper + + return deco + + +# --------------------------------------------------------------------------- +# CSRF koruması +# --------------------------------------------------------------------------- +def ensure_csrf_token(): + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return token + + +def validate_csrf(): + token = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token") + expected = session.get("csrf_token") + if not token or not expected: + return False + return hmac.compare_digest(str(token), str(expected)) + + +# --------------------------------------------------------------------------- +# Girdi sanitizasyonu / doğrulaması (XSS, çok uzun girdiler) +# --------------------------------------------------------------------------- +_USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$") +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") + + +def clean_text(value, max_len): + """Kontrol karakterlerini temizle, kırp, uzunluğu sınırla.""" + if value is None: + return "" + value = str(value) + value = _CONTROL_RE.sub("", value) + value = value.strip() + return value[:max_len] + + +def clean_multiline(value, max_len): + return clean_text(value, max_len) + + +def validate_username(value): + value = clean_text(value, 32) + if not _USERNAME_RE.match(value): + return None + return value + + +def validate_email(value): + value = clean_text(value, 120) + if len(value) > 120 or not _EMAIL_RE.match(value): + return None + return value.lower() + + +def validate_link(value): + value = clean_text(value, 500) + if not value: + return "" + if not re.match(r"^https?://[^\s]+$", value): + return None + if len(value) > 500: + return None + return value + + +# --------------------------------------------------------------------------- +# İstek öncesi / sonrası filtreler +# --------------------------------------------------------------------------- +def before_request(): + if request.path.startswith("/static"): + return None + if not check_limit("global", 600, 300): + abort(429) + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + if not validate_csrf(): + abort(400, description="CSRF doğrulaması başarısız.") + return None + + +CSP = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https://picsum.photos; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; form-action 'self'; object-src 'none'" +) + + +def after_request(response): + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("X-XSS-Protection", "0") + response.headers.setdefault("Permissions-Policy", "camera=(), geolocation=(), microphone=()") + response.headers.setdefault("Content-Security-Policy", CSP) + if request.path.startswith("/admin"): + response.headers["Cache-Control"] = "no-store, max-age=0" + return response diff --git a/setup_admins.py b/setup_admins.py new file mode 100644 index 0000000..4d2dc0d --- /dev/null +++ b/setup_admins.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Admin hesaplarını PROJE DIŞINA kaydeder: ~/.config/key-ajansi/admins.json + +Kullanım: + python3 setup_admins.py # rastgele güçlü şifreler üretir + ADMIN1_PW=... ADMIN2_PW=... python3 setup_admins.py # kendi şifrenizi verin + +Dosyada yalnızca şifrelenmiş (hash) kopyalar saklanır; düz şifre asla yazılmaz. +""" +import json +import os +import secrets +import sys + +from werkzeug.security import generate_password_hash + +import security + +ADMIN1_USER = "eymenmert" +ADMIN1_NAME = "Eymen Mert YİĞİT" +ADMIN2_USER = "mehmedkudret" +ADMIN2_NAME = "Mehmed Kudret ERDOĞMUŞ" + + +def main(): + pw1 = os.environ.get("ADMIN1_PW") or secrets.token_urlsafe(12) + pw2 = os.environ.get("ADMIN2_PW") or secrets.token_urlsafe(12) + + if security.CONFIG_PATH == security.DEFAULT_CONFIG_PATH and os.path.exists(security.CONFIG_PATH): + sys.exit("Yapılandırma zaten var. Sıfırlamak için dosyayı silin:\n " + security.CONFIG_PATH) + + payload = { + "secret": secrets.token_urlsafe(48), + "admins": [ + { + "username": ADMIN1_USER, + "display_name": ADMIN1_NAME, + "password_hash": generate_password_hash(pw1, method="pbkdf2:sha256"), + }, + { + "username": ADMIN2_USER, + "display_name": ADMIN2_NAME, + "password_hash": generate_password_hash(pw2, method="pbkdf2:sha256"), + }, + ], + } + + os.makedirs(os.path.dirname(security.CONFIG_PATH), exist_ok=True) + with open(security.CONFIG_PATH, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, ensure_ascii=False) + os.chmod(security.CONFIG_PATH, 0o600) + + print("Admin yapılandırması kaydedildi:", security.CONFIG_PATH) + print() + print(f" Admin 1 | kullanıcı adı: {ADMIN1_USER} | şifre: {pw1}") + print(f" Admin 2 | kullanıcı adı: {ADMIN2_USER} | şifre: {pw2}") + print() + print("Bu şifreleri güvenli bir yere not edin; bir daha gösterilmez.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..70a9375 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,486 @@ +:root { + --bg: #ffffff; + --bg-soft: #f7f6f2; + --text: #17150f; + --muted: rgba(23, 21, 15, 0.45); + --line: #e9e6df; + --card: #ffffff; + --accent: #9a8b6a; + --accent-soft: rgba(154, 139, 106, 0.12); + --danger: #b3402f; + --shadow: 0 18px 45px rgba(23, 21, 15, 0.06); +} + +[data-theme="dark"] { + --bg: #101014; + --bg-soft: #17171c; + --text: #f2efe8; + --muted: rgba(242, 239, 232, 0.42); + --line: #292a31; + --card: #17171c; + --accent: #c9b586; + --accent-strong: rgba(201, 181, 134, 0.14); + --danger-strong: rgba(179, 64, 47, 0.2); + --shadow: 0 18px 45px rgba(0, 0, 0, 0.5); +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Montserrat", sans-serif; + font-weight: 300; + line-height: 1.65; + transition: background 0.35s ease, color 0.35s ease; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.wrap { + max-width: 1080px; + margin: 0 auto; + padding: 0 24px 40px; +} + +/* ---------------- Üst bar ---------------- */ +.topbar { + display: flex; + align-items: center; + gap: 22px; + max-width: 1080px; + margin: 0 auto; + padding: 22px 24px 10px; +} + +.brand { + font-family: "Cormorant Garamond", serif; + font-weight: 700; + font-size: 1.45rem; + letter-spacing: 0.04em; + color: var(--text); +} +.brand:hover { text-decoration: none; } +.brand::after { content: " ·"; color: var(--accent); } + +.nav { + margin-left: auto; + display: flex; + align-items: center; + gap: 22px; + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.nav a { color: var(--text); opacity: 0.75; } +.nav a:hover { opacity: 1; text-decoration: none; } + +.inline-form { display: inline; } + +.link-btn { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--text); + opacity: 0.75; + letter-spacing: inherit; + text-transform: inherit; + cursor: pointer; +} +.link-btn:hover { opacity: 1; } + +/* Tema anahtarı — en sol üst köşe */ +.theme-toggle { + position: relative; + width: 52px; + height: 28px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg-soft); + cursor: pointer; + flex: none; + display: inline-flex; + align-items: center; + padding: 0 5px; + font-size: 12px; + color: var(--muted); + transition: background 0.3s ease; +} +.tt-sun, .tt-moon { position: relative; z-index: 1; line-height: 1; } +.tt-moon { margin-left: auto; } +.tt-knob { + position: absolute; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--accent); + transition: left 0.25s ease; +} +[data-theme="dark"] .tt-knob { left: 27px; } +[data-theme="dark"] .tt-sun { opacity: 0.35; } + +/* ---------------- Kahraman ---------------- */ +.hero { + text-align: center; + padding: 90px 0 60px; +} +.hero-title { + font-family: "Cormorant Garamond", serif; + font-weight: 600; + font-size: clamp(2.8rem, 8vw, 5rem); + letter-spacing: 0.015em; + line-height: 1.05; + margin: 0; +} +.hero-sub { + margin-top: 18px; + font-size: 0.62rem; + letter-spacing: 0.42em; + text-transform: uppercase; + opacity: 0.4; +} + +/* ---------------- Bölümler ---------------- */ +.section { margin-top: 64px; } +.section-title { + font-family: "Cormorant Garamond", serif; + font-weight: 600; + font-size: 1.9rem; + margin: 0 0 28px; +} + +/* ---------------- Çalışan pencereleri ---------------- */ +.emp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 30px; +} +.emp-card { + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; + background: var(--card); + box-shadow: var(--shadow); +} +.slider { position: relative; overflow: hidden; } +.slides { position: relative; width: 100%; aspect-ratio: 16 / 10; } +.slides img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.7s ease; +} +.slides img.active { opacity: 1; } + +.slider-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + width: 38px; + height: 38px; + border: none; + border-radius: 50%; + background: rgba(15, 15, 15, 0.35); + color: #fff; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.25s ease, background 0.25s ease; +} +.slider:hover .slider-btn { opacity: 1; } +.slider-btn:hover { background: rgba(15, 15, 15, 0.6); } +.slider-btn.prev { left: 10px; } +.slider-btn.next { right: 10px; } + +.dots { + position: absolute; + bottom: 12px; + left: 0; + right: 0; + display: flex; + gap: 6px; + justify-content: center; + z-index: 2; +} +.dots span { + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} +.dots span.active { background: #fff; transform: scale(1.35); } + +.emp-col { min-width: 0; } +.emp-name { + font-family: "Cormorant Garamond", serif; + font-weight: 700; + font-size: 2.1rem; + padding: 0 0 16px; + margin: 0; + text-align: center; + letter-spacing: 0.02em; +} + +/* ---------------- Projeler ---------------- */ +.proj-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; +} +.proj-card { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--card); + padding: 22px; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--shadow); +} +.proj-title { + font-family: "Cormorant Garamond", serif; + font-weight: 600; + font-size: 1.6rem; + margin: 0 0 8px; +} +.proj-tags { margin: 0 0 10px; } +.tag { + display: inline-block; + font-size: 0.66rem; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 999px; + background: var(--accent-strong); + color: var(--accent); + margin: 0 5px 5px 0; +} +.proj-desc { + margin: 0 0 12px; + color: var(--text); + opacity: 0.8; +} +.proj-date { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.06em; + color: var(--muted); +} +.proj-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 18px; +} +.empty-note { color: var(--muted); } + +/* ---------------- Butonlar / Formlar ---------------- */ +.btn { + display: inline-block; + border: 1px solid transparent; + border-radius: 10px; + padding: 12px 20px; + font-family: "Montserrat", sans-serif; + font-size: 0.8rem; + letter-spacing: 0.09em; + text-transform: uppercase; + cursor: pointer; + transition: filter 0.2s ease, background 0.2s ease; +} +.btn:hover { text-decoration: none; filter: brightness(1.06); } +.btn-primary { background: var(--accent); color: #fff; } +.btn-ghost { + background: transparent; + border-color: var(--line); + color: var(--text); +} +.btn-danger { + background: var(--danger-strong, var(--accent-strong)); + border-color: transparent; + color: var(--text); +} +.btn-block { width: 100%; } + +.form-card { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--card); + padding: 34px 32px; + max-width: 460px; + width: 100%; +} +.form-card-wide { max-width: 640px; } +.form-title { + font-family: "Cormorant Garamond", serif; + font-weight: 600; + font-size: 1.8rem; + margin: 0 0 18px; +} +.form-card label { + display: block; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 16px; +} +.form-card input, +.form-card textarea { + display: block; + width: 100%; + margin-top: 7px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg-soft); + color: var(--text); + font-family: inherit; + font-size: 0.95rem; + font-weight: 300; +} +.form-card input:focus, +.form-card textarea:focus { + outline: none; + border-color: var(--accent); + background: var(--bg); +} +textarea { resize: vertical; } + +.form-note { + font-size: 0.82rem; + color: var(--muted); + margin: 18px 0 0; +} +.form-note-center { text-align: center; } +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.auth-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 30px; + align-items: start; + padding-top: 60px; +} +.auth-single { + display: flex; + justify-content: center; + padding-top: 60px; +} +.auth-single .form-card { max-width: 460px; } + +.request-panel { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--card); + padding: 34px 32px; + box-shadow: var(--shadow); +} +.request-panel-text { font-size: 0.92rem; margin: 0 0 20px; } + +/* ---------------- Flash ---------------- */ +.flash-wrap { + max-width: 1080px; + margin: 14px auto 0; + padding: 0 24px; +} +.flash { + border-radius: 10px; + padding: 12px 16px; + font-size: 0.84rem; + margin-bottom: 8px; +} +.flash-success { background: var(--accent-strong); color: var(--text); } +.flash-error { background: var(--danger-strong, rgba(179, 64, 47, 0.15)); color: var(--text); } +.flash-info { background: var(--bg-soft); color: var(--text); } + +/* ---------------- Admin listeler ---------------- */ +.admin-list { display: flex; flex-direction: column; gap: 12px; } +.admin-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px 18px; + background: var(--card); +} +.admin-row-main { display: flex; flex-direction: column; gap: 2px; } +.admin-row-meta { + font-size: 0.72rem; + color: var(--muted); + letter-spacing: 0.04em; +} +.admin-row-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } +.admin-row-actions .btn { padding: 9px 16px; } + +/* Gelen kutusu (e-posta görünümü) */ +.inbox { display: flex; flex-direction: column; gap: 14px; } +.mail { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--card); + padding: 20px 22px; + box-shadow: var(--shadow); +} +.mail-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; +} +.mail-subject { + font-family: "Cormorant Garamond", serif; + font-weight: 700; + font-size: 1.45rem; +} +.mail-time { font-size: 0.72rem; color: var(--muted); white-space: nowrap; } +.mail-from { font-size: 0.82rem; color: var(--muted); margin-top: 4px; } +.mail-body { + margin-top: 14px; + padding: 16px; + border-radius: 10px; + background: var(--bg-soft); + white-space: pre-wrap; + word-break: break-word; +} +.mail-actions { margin-top: 14px; display: flex; gap: 8px; } + +footer { + max-width: 1080px; + margin: 60px auto 0; + padding: 0 24px 40px; + text-align: center; +} +.footer p { + font-size: 0.66rem; + letter-spacing: 0.3em; + text-transform: uppercase; + color: var(--muted); +} + +@media (max-width: 620px) { + .topbar { flex-wrap: wrap; gap: 12px; } + .nav { margin-left: 0; width: 100%; flex-wrap: wrap; gap: 16px; } + .field-grid { grid-template-columns: 1fr; } + .hero { padding-top: 56px; } +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..da863af --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,95 @@ +/* Tema anahtarı + slider'lar + flash otomatik kapanma */ +(function () { + "use strict"; + + // ---- Dark / light tema ---- + var root = document.documentElement; + var stored = null; + try { stored = localStorage.getItem("key-theme"); } catch (e) {} + if (stored === "dark" || stored === "light") { + root.setAttribute("data-theme", stored); + } else { + root.setAttribute("data-theme", "light"); + } + + var toggle = document.getElementById("themeToggle"); + if (toggle) { + toggle.addEventListener("click", function () { + var next = root.getAttribute("data-theme") === "dark" ? "light" : "dark"; + root.setAttribute("data-theme", next); + try { localStorage.setItem("key-theme", next); } catch (e) {} + }); + } + + // ---- Slider'lar ---- + document.querySelectorAll(".slider").forEach(function (slider) { + var images = Array.prototype.slice.call(slider.querySelectorAll(".slides img")); + if (images.length === 0) return; + var current = 0; + var interval = parseInt(slider.getAttribute("data-interval") || "4000", 10); + var timer = null; + + var dotsBox = slider.querySelector(".dots"); + images.forEach(function (_, i) { + var d = document.createElement("span"); + d.setAttribute("role", "button"); + d.setAttribute("aria-label", "Görsel " + (i + 1)); + d.addEventListener("click", function (ev) { + ev.stopPropagation(); + go(i); + restart(); + }); + dotsBox.appendChild(d); + }); + var dots = Array.prototype.slice.call(dotsBox.children); + + function show() { + images.forEach(function (img, i) { + img.classList.toggle("active", i === current); + }); + dots.forEach(function (d, i) { + d.classList.toggle("active", i === current); + }); + } + + function go(i) { + current = (i + images.length) % images.length; + show(); + } + + function next() { go(current + 1); } + function prev() { go(current - 1); } + function restart() { + if (timer) clearInterval(timer); + timer = setInterval(next, interval); + } + + slider.querySelector(".next").addEventListener("click", function (ev) { + ev.stopPropagation(); + next(); + restart(); + }); + slider.querySelector(".prev").addEventListener("click", function (ev) { + ev.stopPropagation(); + prev(); + restart(); + }); + + slider.addEventListener("mouseenter", function () { + if (timer) clearInterval(timer); + }); + slider.addEventListener("mouseleave", restart); + + show(); + restart(); + }); + + // ---- Flash mesajları ---- + setTimeout(function () { + document.querySelectorAll(".flash").forEach(function (el) { + el.style.transition = "opacity .8s ease"; + el.style.opacity = "0"; + setTimeout(function () { el.remove(); }, 800); + }); + }, 5000); +})(); \ No newline at end of file diff --git a/templates/admin/projects.html b/templates/admin/projects.html new file mode 100644 index 0000000..71a27f5 --- /dev/null +++ b/templates/admin/projects.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Proje Yükleme — Key Ajansı{% endblock %} +{% block content %} + +
+
+

Proje Yükle

+

Yeni projenizi GitHub'a benzer şekilde ekleyin. İstediğiniz zaman kaldırabilir veya dosyayı geri indirebilirsiniz.

+ +
+ + + + + + + +
+
+
+ +
+

Yüklenen Projeler

+ {% if projects %} +
+ {% for p in projects %} +
+
+ {{ p.title }} + {{ p.created_at }} · {% if p.size %}{{ (p.size / 1024) | round(1) }} KB{% else %}dosya yok{% endif %} +
+
+ Gör + {% if p.stored_name %} + İndir + {% endif %} +
+ + +
+
+
+ {% endfor %} +
+ {% else %} +

Henüz proje yüklenmedi.

+ {% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/admin/requests.html b/templates/admin/requests.html new file mode 100644 index 0000000..f717154 --- /dev/null +++ b/templates/admin/requests.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Talepler — Key Ajansı{% endblock %} +{% block content %} + +
+

Gelen Talepler

+ {% if requests %} +
+ {% for r in requests %} +
+
+ {{ r.subject }} + {{ r.created_at }} +
+
Kimden: {{ r.sender_name }} <{{ r.sender_email }}>
+
{{ r.body }}
+
+
+ + +
+
+
+ {% endfor %} +
+ {% else %} +

Henüz talep yok.

+ {% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..1ae2bfc --- /dev/null +++ b/templates/base.html @@ -0,0 +1,64 @@ + + + + + + + {% block title %}Key Ajansı Portfolyosu{% endblock %} + + + + + + + +
+ + Key Ajansı + +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} +{% endwith %} + +
+{% block content %}{% endblock %} +
+ +
+

Key Ajansı Portfolyosu — an unofficial agency

+
+ + +{% block scripts %}{% endblock %} + + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..8ecd008 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Hata — Key Ajansı{% endblock %} +{% block content %} +
+

{{ code }}

+

{{ message }}

+

Ana Menüye Dön

+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ab0a76a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block content %} + +
+

Key Ajansı Portfolyosu

+

an unofficial agency

+
+ +
+

Projelerimiz:

+ {% if projects %} +
+ {% for p in projects %} +
+
+

{{ p.title }}

+ {% if p.tags %} +

{% for t in p.tags.split(',') if t.strip() %}{{ t.strip() }}{% endfor %}

+ {% endif %} +

{{ p.description }}

+

{{ p.created_at }}

+
+
+ {% if p.link %}Bağlantı ↗{% endif %} + {% if p.stored_name %} + Dosyayı İndir + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

Henüz proje yüklenmedi.

+ {% endif %} +
+ +
+

Ajansdaki Çalışanlarımız:

+
+ +
+

Eymen Mert YİĞİT

+
+
+
+ + + + +
+ + +
+
+
+
+ +
+

Mehmed Kudret ERDOĞMUŞ

+
+
+
+ + + + +
+ + +
+
+
+
+ +
+
+ +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..b598449 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Giriş Yap — Key Ajansı{% endblock %} +{% block content %} + +
+
+

Giriş Yap

+
+ + + + +
+

Hesabınız yok mu? Kayıt olun

+
+ + +
+ +{% endblock %} diff --git a/templates/new_talep.html b/templates/new_talep.html new file mode 100644 index 0000000..7859f78 --- /dev/null +++ b/templates/new_talep.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Yeni Talep — Key Ajansı{% endblock %} +{% block content %} + +
+
+

Yeni Talep

+

Bir e-posta yazar gibi talebinizi oluşturun.

+ +
+ +
+ + +
+ + + +

Geri Dön

+
+
+
+ +{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..12436d1 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Kayıt Ol — Key Ajansı{% endblock %} +{% block content %} + +
+
+

Kayıt Ol

+
+ + + + + + +
+

Zaten hesabınız var mı? Giriş yapın

+
+
+ +{% endblock %}