Theme Development Guide
Everything you need to build custom themes for TubePress.
Overview
TubePress themes are self-contained folders inside the themes/ directory. Each theme controls the entire front-end appearance of your site: HTML structure, CSS styling, JavaScript behavior, and helper functions.
A theme requires only two files to work: theme.json (metadata) and templates/layout.php (master layout). Everything else is optional — TubePress will automatically fall back to the default Simply theme for any missing template.
Key concept: TubePress renders each page template into a $content variable, then passes it to layout.php. Your layout wraps all pages with a consistent header, footer, and navigation.
File Structure
A complete theme directory looks like this:
Fallback system: You don't need to create every template. If a template is missing from your theme, TubePress automatically uses the corresponding file from the simply theme. Start with just layout.php and override templates one by one.
theme.json
This file describes your theme. It is read by the admin panel to display theme information.
{
"name": "My Theme",
"description": "A custom theme for TubePress",
"version": "1.0.0",
"author": "Your Name",
"screenshot": "assets/images/screenshot.png",
"colors": {
"primary": "#2563eb",
"primary-dark": "#1d4ed8",
"secondary": "#7c3aed",
"background": "#ffffff",
"surface": "#f8fafc",
"text": "#1e293b",
"text-muted": "#64748b"
},
"settings": {
"videos_per_row": 4,
"show_sidebar": false,
"hero_enabled": false
}
}
| Field | Type | Description |
|---|---|---|
name | string | Display name shown in admin |
description | string | Short description of the theme |
version | string | Semantic version number |
author | string | Theme author name |
screenshot | string | Path to preview image (relative to theme folder) |
colors | object | Color palette (informational, used by admin) |
settings | object | Theme-specific default settings |
functions.php
This file runs once when the theme boots. Use it to register CSS/JS assets and define helper functions used by your templates.
<?php
declare(strict_types=1);
// Register your CSS and JS assets
ThemeRenderer::enqueueCSS(ThemeManager::assetUrl('css/style.css'), 1);
ThemeRenderer::enqueueJS(ThemeManager::assetUrl('js/app.js'), 1);
// Define a helper function for video cards
function myThemeVideoCard(array $video): string
{
// Allow plugins to track impressions (e.g. CTR plugin)
HookSystem::doAction('video.card.before', $video);
$title = htmlspecialchars($video['title']);
$slug = htmlspecialchars($video['slug']);
$duration = Format::duration((int)($video['duration'] ?? 0));
$views = Format::number((int)($video['views'] ?? 0));
$timeAgo = Format::timeAgo($video['published_at'] ?? $video['created_at']);
$thumb = htmlspecialchars(VideoAsset::thumbUrl($video));
$preview = htmlspecialchars(VideoAsset::previewUrl($video));
return <<<HTML
<article class="video-card">
<a href="/video/{$slug}">
<img src="{$thumb}" alt="{$title}" loading="lazy">
<span class="duration">{$duration}</span>
</a>
<h3><a href="/video/{$slug}">{$title}</a></h3>
<span>{$views} views · {$timeAgo}</span>
</article>
HTML;
}
// Define a helper function for pagination
function myThemePagination(Pagination $pagination, string $baseUrl = ''): string
{
if ($pagination->totalPages <= 1) return '';
$html = '<nav class="pagination">';
if ($pagination->hasPrev()) {
$html .= '<a href="' . htmlspecialchars($pagination->prevUrl($baseUrl)) . '">« Prev</a>';
}
foreach ($pagination->pages() as $p) {
if ($p === '...') {
$html .= '<span class="ellipsis">…</span>';
} elseif ($p === $pagination->page) {
$html .= '<span class="active">' . $p . '</span>';
} else {
$url = $baseUrl ? $baseUrl . '?page=' . $p : '?' . http_build_query(['page' => $p] + $_GET);
$html .= '<a href="' . htmlspecialchars($url) . '">' . $p . '</a>';
}
}
if ($pagination->hasNext()) {
$html .= '<a href="' . htmlspecialchars($pagination->nextUrl($baseUrl)) . '">Next »</a>';
}
$html .= '</nav>';
return $html;
}
Priority parameter: enqueueCSS(url, priority) and enqueueJS(url, priority) accept a priority number. Lower numbers load first. Use 1 for your main assets.
Naming convention: Prefix your helper functions with your theme name (e.g. myThemeVideoCard) to avoid naming collisions with other themes or plugins. The built-in Simply theme uses simplyVideoCard() and simplyPagination().
Layout Template
layout.php is the skeleton of every page. It receives a $content variable containing the rendered page template. You must echo it inside your <main> element.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= isset($pageTitle)
? htmlspecialchars($pageTitle).' - '.htmlspecialchars($_siteName)
: htmlspecialchars($_siteName) ?></title>
<?php if (!empty($_siteFavicon)): ?>
<link rel="icon" href="/uploads/branding/<?= htmlspecialchars($_siteFavicon) ?>">
<?php endif; ?>
<?= ThemeRenderer::renderCSS() ?>
<?= $_colorOverrides ?>
<?php HookSystem::doAction('head.meta'); ?>
</head>
<body class="cols-<?= (int)($_videosPerRow ?? 4) ?> <?= ThemeRenderer::bodyClass() ?>">
<header>
<a href="/">
<?php if (!empty($_siteLogo)): ?>
<img src="/uploads/branding/<?= htmlspecialchars($_siteLogo) ?>"
alt="<?= htmlspecialchars($_siteName) ?>">
<?php else: ?>
<?= htmlspecialchars($_siteName) ?>
<?php endif; ?>
</a>
<nav>
<?php foreach ($_menuItems as $mi): ?>
<?php if ($mi['enabled']): ?>
<a href="<?= $mi['url'] ?>"
class="<?= in_array($_template, $mi['templates']) ? 'active' : '' ?>">
<?= $mi['label'] ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</nav>
<?php if ($_menuSearch): ?>
<form action="/search" method="GET">
<input type="text" name="q" placeholder="Search...">
</form>
<?php endif; ?>
<?php if ($_user): ?>
<a href="/profile"><?= htmlspecialchars($_user['username']) ?></a>
<a href="/logout">Logout</a>
<?php else: ?>
<a href="/login">Login</a>
<a href="/register">Sign Up</a>
<?php endif; ?>
</header>
<main><?= $content ?></main>
<footer class="footer footer__cols-<?= count($_footerColumns) ?>">
<!-- Render columns from Footer Builder (see Footer Builder section) -->
<?php foreach ($_footerColumns as $col): ?>
<div><!-- render by $col['type'] --></div>
<?php endforeach; ?>
© <?= date('Y').' '.htmlspecialchars($_siteName) ?>
</footer>
<?= ThemeRenderer::renderJS() ?>
<?php HookSystem::doAction('footer.scripts'); ?>
</body>
</html>
Required calls: Your layout must include:
ThemeRenderer::renderCSS()in the<head>ThemeRenderer::renderJS()before</body>HookSystem::doAction('head.meta')andHookSystem::doAction('footer.scripts')for plugins to inject their assets
Template List
Each template receives specific variables from its controller. Here is the complete list:
| Template | Route | Variables |
|---|---|---|
home.php | / | $videos, $pagination, $sort |
video.php | /video/{slug} | $video, $categories, $tags, $performers, $studios, $comments, $similar, $recommended, $userVote, $isFavorited |
categories.php | /categories | $categories |
category.php | /category/{slug} | $category, $videos, $pagination |
tags.php | /tags | $tags |
tag.php | /tag/{slug} | $tag, $videos, $pagination |
performers.php | /pornstars | $performers, $pagination |
performer.php | /pornstar/{slug} | $performer, $videos, $pagination |
studios.php | /studios | $studios, $pagination |
studio.php | /studio/{slug} | $studio, $videos, $pagination |
search.php | /search?q=... | $query, $videos, $pagination, $total |
favorites.php | /favorites | $videos, $pagination |
history.php | /history | $videos, $pagination |
page.php | /{slug} | $page, $alternates |
auth/login.php | /login | $error |
auth/register.php | /register | $error |
auth/profile.php | /profile | $error, $success, $profileUser |
404.php | any unmatched | (none) |
Note: Every template also receives all global variables ($_siteName, $_user, $pageTitle, etc.) in addition to the variables listed above.
Variables in Detail
Upload Directories
All uploaded files live under public/uploads/ with the following subfolders:
| Subdirectory | Contains |
|---|---|
/uploads/videos/{hash}/ | Per-video folder — contains video.mp4, thumb.jpg, thumb_small.jpg, preview.mp4 (see VideoAsset) |
/uploads/performers/ | Pornstar photos |
/uploads/studios/ | Studio logos |
/uploads/branding/ | Site logo, favicon, background image |
Per-video folder storage: Each video gets its own folder at /uploads/videos/{16-char-hex}/ with fixed filenames (thumb.jpg, video.mp4, etc.). Use the VideoAsset helper class to resolve all video paths — never construct paths manually. See VideoAsset.
Full URLs: The thumbnail field may contain a full HTTP URL for embed videos (no local folder). The VideoAsset::thumbUrl($video) method handles both cases automatically.
Video Array
In listing pages (home.php, category.php, etc.), each item in $videos contains:
$video = [
'id' => 42,
'title' => 'Video Title',
'slug' => 'video-title',
'description' => 'Full description...',
'embed_code' => '<iframe ...>', // optional embed HTML, or null
'video_folder' => 'a1b2c3d4e5f67890', // 16-char hex folder name, or null for embed-only
'thumbnail' => 'https://...', // external thumbnail URL (embed videos only), or null
'duration' => 325, // seconds
'views' => 15420,
'likes' => 230,
'dislikes' => 12,
'status' => 'published', // 'draft', 'published', or 'private'
'user_id' => 1, // uploader's user ID
'published_at' => '2026-01-15 14:30:00',
'created_at' => '2026-01-15 14:00:00',
'updated_at' => '2026-01-15 15:00:00',
'performer_name' => 'Performer Name', // first performer or null (listings only)
];
Video assets: Use VideoAsset to resolve all video-related URLs:
VideoAsset::thumbUrl($video) // thumbnail URL (folder-based or external fallback)
VideoAsset::thumbUrl($video, true) // prefer thumb_small.jpg for listings
VideoAsset::previewUrl($video) // preview clip URL, or ''
VideoAsset::videoUrl($video['video_folder']) // main video file URL
Never construct paths like '/uploads/videos/' . $video['video_folder'] . '/thumb.jpg' manually — always use the helper.
On the single video page (video.php), the $video array also includes related entities:
// Additional fields on single video page:
$video['categories'] = [ ['id' => 3, 'name' => '...', 'slug' => '...'], ... ];
$video['tags'] = [ ['id' => 12, 'name' => '...', 'slug' => '...'], ... ];
$video['performers'] = [ ['id' => 5, 'name' => '...', 'slug' => '...'], ... ];
$video['studios'] = [ ['id' => 2, 'name' => '...', 'slug' => '...'], ... ];
Video-specific template variables
| Variable | Type | Description |
|---|---|---|
$similar | array | Related videos (same categories/tags). Plugins can extend the matching criteria via the video.similar filter. |
$recommended | array | Recommended videos (excludes current + similar). Plugins can override the algorithm via the video.recommended filter. |
$userVote | string|null | Current user's vote: 'like', 'dislike', or null |
$isFavorited | bool | Whether the current user has favorited this video |
$comments | array | Nested tree of comment arrays (see below) |
Performer Array
$performer = [
'id' => 5,
'name' => 'Performer Name',
'slug' => 'performer-name',
'bio' => 'Biography text...',
'photo' => 'photo_abc.jpg', // relative to /uploads/performers/
'country' => 'US', // ISO 3166-1 alpha-2 code
'birth_date' => '1995-03-12', // or null
'social_links' => '{"link":"https://..."}', // JSON string, or null
'video_count' => 48,
'created_at' => '2026-01-10 10:00:00',
// Core field (set by admin/front controllers)
'best_video_folder' => 'a1b2c3d4e5f67890', // video_folder of best video, or null
// Plugin fields (added by CTR plugin when active)
'total_ctr' => 12.45, // sum of ctr_score of all videos
];
Social links: The social_links field is a JSON string. Decode it with json_decode($performer['social_links'], true) to get an associative array. The key is link containing a website or social profile URL.
Fallback thumbnail: Use best_video_folder to show a fallback thumbnail when the performer has no photo:
$img = !empty($performer['photo']) ? '/uploads/performers/' . $performer['photo'] : (!empty($performer['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($performer['best_video_folder']) : '');
Studio Array
$studio = [
'id' => 2,
'name' => 'Studio Name',
'slug' => 'studio-name',
'description' => 'Studio description...',
'logo' => 'logo_abc.jpg', // relative to /uploads/studios/, or null
'website' => 'https://example.com', // or null
'video_count' => 120,
'created_at' => '2026-01-10 10:00:00',
'updated_at' => '2026-01-10 10:00:00',
// Core field
'best_video_folder' => 'a1b2c3d4e5f67890', // video_folder of best video, or null
// Plugin fields (added by CTR plugin when active)
'total_ctr' => 25.80, // sum of ctr_score of all videos
];
Fallback thumbnail: Use best_video_folder to show a fallback thumbnail when the studio has no logo:
$img = !empty($studio['logo']) ? '/uploads/studios/' . $studio['logo'] : (!empty($studio['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($studio['best_video_folder']) : '');
Category Array
$category = [
'id' => 3,
'name' => 'Category Name',
'slug' => 'category-name',
'description' => 'Category description...',
'thumbnail' => 'cat.jpg', // relative to /uploads/thumbnails/, or null
'sort_order' => 0,
'video_count' => 156, // computed, not stored
// Core field
'best_video_folder' => 'a1b2c3d4e5f67890', // video_folder of best video, or null
];
Fallback thumbnail: Use best_video_folder to show a fallback thumbnail when the category has no custom thumbnail:
$img = !empty($category['thumbnail']) ? '/uploads/thumbnails/' . $category['thumbnail'] : (!empty($category['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($category['best_video_folder']) : '');
Tag Array
$tag = [
'id' => 12,
'name' => 'Tag Name',
'slug' => 'tag-name',
'video_count' => 89, // computed, not stored
];
Comment Array
$comment = [
'id' => 101,
'video_id' => 42,
'user_id' => 7,
'username' => 'johndoe', // joined from users table
'body' => 'Comment text...',
'parent_id' => null, // null = top-level, int = reply
'status' => 'approved', // 'pending', 'approved', or 'spam'
'created_at' => '2026-02-10 09:15:00',
'replies' => [ ... ], // nested array of child comments
];
Page Array
$page = [
'id' => 1,
'title' => 'About Us', // translated if available
'slug' => 'about-us', // translated if available
'body' => '<p>Rich HTML...</p>', // TinyMCE content, translated if available
'meta_title' => 'About Us | My Site', // SEO title (fallback to default lang)
'meta_description' => 'Learn about us...', // SEO description (fallback to default lang)
'status' => 'published',
'show_in_footer' => 1, // 1 = show in footer, 0 = don't
'sort_order' => 0,
'created_at' => '2026-01-10 10:00:00',
];
// $alternates = hreflang URLs for multilingual pages
$alternates = [
'en' => 'https://example.com/about-us',
'fr' => 'https://example.com/fr/a-propos',
'x-default' => 'https://example.com/about-us',
];
Pages & Translations: Pages support multilingual content. The $page array automatically contains translated fields (title, slug, body, meta_title, meta_description) when a translation exists for the current locale. If no translation exists, the default language content is used as fallback.
Page URLs use a clean /{slug} pattern (no /page/ prefix). The page route is a catch-all registered last, so it won't conflict with other routes. Use url('/' . $page['slug']) to generate locale-aware URLs.
The page body contains rich HTML from TinyMCE. Wrap it in <div class="rich-content"> and provide CSS for headings, lists, tables, blockquotes, code blocks, images, etc. in your theme.
Use $alternates for hreflang tags in layout.php:
<?php if (!empty($alternates)): foreach ($alternates as $hl => $hu): ?>
<link rel="alternate" hreflang="<?= htmlspecialchars($hl) ?>" href="<?= htmlspecialchars($hu) ?>">
<?php endforeach; endif; ?>
Global Variables
These variables are available in every template, including layout.php:
| Variable | Type | Description |
|---|---|---|
$content | string | Rendered page HTML (only in layout.php) |
$_template | string | Current template name (home, video, category...) |
$_siteName | string | Site name from admin settings |
$_siteDescription | string | Site description from admin settings |
$_siteUrl | string | Site URL from admin settings |
$_videosPerRow | int | Grid columns setting (4, 5, or 6) |
$_user | array|null | Logged-in user array or null if guest |
$_footerPages | array | CMS pages marked for the footer (in_footer = 1) — backward compat |
$_footerColumns | array | Resolved footer columns from Footer Builder (see Footer Builder) |
$_footerBottom | array | Footer bottom bar config: show_copyright, show_disclaimer, show_social, custom texts |
$_socialLinks | array | Social media URLs keyed by platform: ['twitter' => 'https://...', ...] |
$pageTitle | string | Page-specific title set by the controller |
$_siteLogo | string | Logo filename (relative to /uploads/branding/), or empty for text logo |
$_siteFavicon | string | Favicon filename (relative to /uploads/branding/), or empty |
$_siteBackground | string | Background image filename (relative to /uploads/branding/), or empty |
$_siteBackgroundMode | string | Background display mode: cover, contain, or repeat |
$_colorOverrides | string | A <style> tag with CSS variable overrides from admin, or empty string |
$_menuItems | array | Navigation items array (see Appearance Integration) |
$_menuSearch | bool | Whether the search bar should be displayed |
$_cardShowDuration | bool | Show duration badge on card thumbnails (see Video Cards) |
$_cardShowTitle | bool | Show video title on cards |
$_cardMetaLeft | string | Left meta info: views, likes, time, or none |
$_cardMetaRight | string | Right meta info: views, likes, time, or none |
$_cardGridGap | int | Gap between cards in pixels (0–30) |
$_cardBorderRadius | int | Card border-radius in pixels (0–24) |
$_cardThumbnailHover | bool | Enable zoom effect on thumbnail hover |
$_cardTitleLines | int | Title lines: 1 (ellipsis) or 2 (line-clamp) |
$_registrationEnabled | bool | Whether user registration is enabled |
$_commentsEnabled | bool | Whether comments are enabled |
$_captchaProvider | string | Active captcha provider: 'none', 'v2', or 'v3' |
$_captchaSiteKey | string | Google reCAPTCHA site key (empty if not configured) |
$_captchaOnRegister | bool | Whether captcha is required on the registration form |
$_captchaOnComment | bool | Whether captcha is required on comment forms |
$_watchShowViews | bool | Show view count on video page |
$_watchShowDuration | bool | Show duration on video page |
$_watchShowDate | bool | Show publish date on video page |
$_watchShowLikes | bool | Show like/dislike buttons on video page |
$_watchShowFavorites | bool | Show favorite button on video page |
$_watchShowPornstars | bool | Show performers on video page |
$_watchShowStudios | bool | Show studios on video page |
$_watchShowCategories | bool | Show categories on video page |
$_watchShowTags | bool | Show tags on video page |
$_locale | string | Current locale code (e.g. 'en', 'fr') |
$_direction | string | 'ltr' or 'rtl' |
$_isRtl | bool | Whether current language is right-to-left |
$_availableLangs | array | Array of available languages |
$_langPrefix | string | URL prefix for current language (e.g. '/fr' or '') |
Use $_template to highlight the active navigation item:
<a href="/" class="<?= $_template === 'home' ? 'active' : '' ?>">Home</a>
<a href="/categories" class="<?= in_array($_template, ['categories','category']) ? 'active' : '' ?>">Categories</a>
The $_user array (when logged in) contains:
$_user = [
'id' => 1,
'username' => 'johndoe',
'email' => 'john@example.com',
'role' => 'user', // 'admin' or 'user'
];
ThemeRenderer API
All methods are static. Call them with ThemeRenderer::methodName().
| Method | Returns | Description |
|---|---|---|
render($template, $data) | void | Core method — renders a template with data, sets global variables, applies theme.template_data filter, wraps with layout.php |
enqueueCSS($url, $priority) | void | Register a CSS file for <head>. Lower priority loads first. |
enqueueJS($url, $priority) | void | Register a JS file for before </body>. Lower priority loads first. |
renderCSS() | string | Returns all CSS <link> tags as HTML string |
renderJS() | string | Returns all JS <script> tags as HTML string |
setBodyClass($class) | void | Set a custom CSS class string on the body element |
bodyClass() | string | Get the current body class string |
partial($name, $data) | void | Include and render a partial from templates/partials/. Echoes output directly. |
buildColorOverrides() | string | Returns a <style> tag with CSS variable overrides from admin color settings, or an empty string if no colors are customized |
Important: partial() echoes output directly (returns void). Use it inline: <?php ThemeRenderer::partial('video-card', ['video' => $v]); ?>
ThemeManager API
All methods are static. Call them with ThemeManager::methodName().
| Method | Returns | Description |
|---|---|---|
boot() | void | Initialize the active theme: loads theme.json and runs functions.php. Called automatically by the core. |
active() | string | Returns the slug of the currently active theme |
activate($slug) | bool | Activate a theme by slug. Returns true on success. |
getAll() | array | Returns an array of all available themes with their metadata |
info($key, $default) | mixed | Read a value from the active theme's theme.json |
assetUrl($path) | string | Returns the public URL to a theme asset.ThemeManager::assetUrl('css/style.css') → /themes/your-theme/assets/css/style.css |
themePath($slug) | string | Filesystem path to a theme directory |
templatePath($template) | string | Resolves a template name to its file path (active theme first, then simply fallback) |
Player (Video Player)
The Player class is a core helper that handles all video playback logic. It automatically detects whether to render an embed iframe or initialize FluidPlayer (the built-in HTML5 video player). Themes don't need to implement any player logic.
Usage
In your video.php template, render the player with a single call:
<?= Player::render($video) ?>
That's it. The Player class handles everything:
- Embed videos (
embed_codeset): renders the iframe inside a.video-playercontainer with 16:9 aspect ratio - Uploaded/local videos (
video_folderset, no embed): renders a<video>tag and initializes FluidPlayer with controls, poster image, and theme color integration. UsesVideoAsset::videoUrl()andVideoAsset::thumbUrl()to resolve file paths. - No video source: returns an empty string
What happens behind the scenes
When Player::render() is called with a video_folder:
- Enqueues
/assets/vendor/fluidplayer/fluidplayer.min.jsviaThemeRenderer::enqueueJS() - Injects FluidPlayer CSS into the
<head>viahead.metahook - Registers the init script via
footer.scriptshook (runs after the JS loads) - Reads the theme's
--primaryCSS variable to match the player controls to your color scheme
All assets are enqueued only once, even if render() is called multiple times.
Generated HTML
Embed:
<div class="video-player">
<iframe src="..." width="100%" height="100%"></iframe>
</div>
FluidPlayer:
<div class="video-player video-player--fluid">
<video id="mainVideo" poster="/uploads/videos/a1b2c3d4e5f67890/thumb.jpg">
<source src="/uploads/videos/a1b2c3d4e5f67890/video.mp4" type="video/mp4">
</video>
</div>
CSS for themes
The core injects the functional CSS for .video-player--fluid automatically. Your theme only needs to style the basic container:
/* Theme CSS — only visual styles needed */
.video-player {
background: #000;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 16/9;
}
.video-player iframe {
width: 100%;
height: 100%;
display: block;
}
No player code in themes: FluidPlayer is part of the CMS core, pre-built and ready to use. Themes do not need to load any player library, write any JavaScript, or handle embed vs upload detection. Just call Player::render($video).
Layout requirement: Your layout must include HookSystem::doAction('head.meta') in the <head> and HookSystem::doAction('footer.scripts') before </body> for the player assets to load correctly.
Helper Classes
Format
| Method | Input | Output |
|---|---|---|
Format::duration(325) | 325 | 05:25 |
Format::number(15420) | 15420 | 15.4K |
Format::timeAgo('2026-02-10 09:00:00') | datetime | 3 days ago |
Format::date('2026-02-10') | date | 10/02/2026 |
Format::fileSize(5242880) | bytes | 5 MB |
Slug
| Method | Description |
|---|---|
Slug::generate('Hello World') | Returns hello-world — converts text to URL-safe slug |
Slug::unique($table, $text, $excludeId) | Generates a unique slug for a database table, appending -2, -3, etc. if needed. $excludeId is optional (used for updates). |
VideoAsset (Video File Helper)
The VideoAsset class centralizes all video file path resolution. Each video gets its own folder at public/uploads/videos/{16-char-hex}/ containing fixed-name files:
| File | Description |
|---|---|
video.mp4 | Main video file (also .webm or .ogg) |
thumb.jpg | Full-size thumbnail |
thumb_small.jpg | Small thumbnail (for grids/listings) |
preview.mp4 | Short preview clip for hover-to-play |
Theme-facing methods
These are the methods you will use most often in theme templates and functions.php:
| Method | Returns | Description |
|---|---|---|
VideoAsset::thumbUrl($video) | string | Full-size thumbnail URL. Checks video_folder first, falls back to thumbnail field (external URL). Returns empty string if neither exists. |
VideoAsset::thumbUrl($video, true) | string | Prefers thumb_small.jpg if it exists, otherwise falls back to full-size thumb. Use this for video card listings for faster loading. |
VideoAsset::previewUrl($video) | string | Preview clip URL, or empty string if no preview exists. |
VideoAsset::videoUrl($folder) | string | Main video file URL. Auto-detects the extension (.mp4, .webm, .ogg). |
VideoAsset::thumbUrlFromFolder($folder) | string | Thumbnail URL from a folder hash directly (used for best_video_folder on categories, performers, studios). |
VideoAsset::assignUniqueThumbFolders(&$items, $joinTable, $fkColumn, $manualField, $orderBy) | void | Assigns deduplicated best_video_folder to a list of entities. See Thumbnail Deduplication below. |
VideoAsset::url($folder, $type) | string | Generic URL builder. $type is 'thumb', 'thumb_small', or 'preview'. |
Usage examples
// In a video card helper function:
$thumb = VideoAsset::thumbUrl($video, true); // small thumb for listings
$preview = VideoAsset::previewUrl($video); // hover preview clip
// On the single video page:
$poster = VideoAsset::thumbUrl($video); // full-size for player poster
$src = VideoAsset::videoUrl($video['video_folder']); // video file URL
// For category/performer/studio fallback thumbnails:
$img = VideoAsset::thumbUrlFromFolder($item['best_video_folder']);
// Deduplicate thumbnails on listing pages (called in models automatically):
VideoAsset::assignUniqueThumbFolders($categories, 'video_categories', 'category_id', 'thumbnail');
VideoAsset::assignUniqueThumbFolders($performers, 'video_performers', 'performer_id', 'photo');
VideoAsset::assignUniqueThumbFolders($studios, 'video_studios', 'studio_id', 'logo');
Never construct paths manually. Always use VideoAsset methods. The helper checks file existence, handles missing folders, and gracefully falls back to empty strings. Manual path construction like '/uploads/videos/' . $folder . '/thumb.jpg' will break when files are missing.
Embed-only videos: Videos with only embed_code (no video_folder) may store an external thumbnail URL in the thumbnail field. VideoAsset::thumbUrl($video) handles this automatically — it returns the external URL when no folder-based thumbnail exists.
Pagination
Pages that list videos receive a $pagination object (instance of Pagination):
| Property / Method | Type | Description |
|---|---|---|
$pagination->page | int | Current page number |
$pagination->perPage | int | Items per page |
$pagination->total | int | Total number of items |
$pagination->totalPages | int | Total number of pages |
$pagination->offset | int | Database offset for current page (useful for queries) |
hasPrev() | bool | Is there a previous page? |
hasNext() | bool | Is there a next page? |
prevUrl($base = '') | string | URL to the previous page. Pass a base URL or leave empty to preserve current $_GET params. |
nextUrl($base = '') | string | URL to the next page. Same $base behavior. |
pages() | array | Array of page numbers and '...' ellipsis markers for building navigation |
Creating pagination in controllers
// Factory method: reads ?page= from URL, computes totalPages and offset
$pagination = Pagination::fromRequest($totalItems, $perPage);
Example template usage
<?php if ($pagination->totalPages > 1): ?>
<nav class="pagination">
<?php if ($pagination->hasPrev()): ?>
<a href="<?= htmlspecialchars($pagination->prevUrl()) ?>">« Prev</a>
<?php endif; ?>
<?php foreach ($pagination->pages() as $p): ?>
<?php if ($p === '...'): ?>
<span class="ellipsis">…</span>
<?php elseif ($p === $pagination->page): ?>
<span class="active"><?= $p ?></span>
<?php else: ?>
<a href="?<?= http_build_query(['page' => $p] + $_GET) ?>"><?= $p ?></a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination->hasNext()): ?>
<a href="<?= htmlspecialchars($pagination->nextUrl()) ?>">Next »</a>
<?php endif; ?>
</nav>
<?php endif; ?>
Appearance Integration
TubePress provides admin-configurable appearance settings that your theme can use for dynamic branding, colors, backgrounds, and navigation. All values are set via Settings → Appearance in the admin panel.
Menu Items ($_menuItems)
The $_menuItems array contains navigation entries with admin-controlled visibility:
$_menuItems = [
[
'key' => 'home', // identifier
'label' => 'Home', // display text
'url' => '/', // link href
'enabled' => true, // admin toggle (always true for Home)
'templates' => ['home'], // templates where this item is "active"
],
[
'key' => 'categories',
'label' => 'Categories',
'url' => '/categories',
'enabled' => true, // false if admin unchecked it
'templates' => ['categories', 'category'],
],
// ... tags, pornstars, studios
];
Use it to build dynamic navigation with active state:
<nav>
<?php foreach ($_menuItems as $mi): ?>
<?php if ($mi['enabled']): ?>
<a href="<?= $mi['url'] ?>"
class="<?= in_array($_template, $mi['templates']) ? 'active' : '' ?>">
<?= $mi['label'] ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</nav>
Color Overrides ($_colorOverrides)
When the admin sets custom colors, $_colorOverrides contains a <style> tag that overrides the theme's CSS variables. Place it after renderCSS() so the overrides take precedence:
<?= ThemeRenderer::renderCSS() ?>
<?= $_colorOverrides ?>
<!-- If no colors are customized, $_colorOverrides is an empty string -->
The overrides map to these CSS variables: --primary, --primary-dark, --secondary, --bg, --bg-surface, --bg-card, --bg-dark, --text, --text-muted, --border.
Logo & Favicon
Use $_siteLogo and $_siteFavicon to show uploaded images or fall back to text:
<!-- Favicon in <head> -->
<?php if (!empty($_siteFavicon)): ?>
<link rel="icon" href="/uploads/branding/<?= htmlspecialchars($_siteFavicon) ?>">
<?php endif; ?>
<!-- Logo in header -->
<a href="/">
<?php if (!empty($_siteLogo)): ?>
<img src="/uploads/branding/<?= htmlspecialchars($_siteLogo) ?>"
alt="<?= htmlspecialchars($_siteName) ?>">
<?php else: ?>
<?= htmlspecialchars($_siteName) ?>
<?php endif; ?>
</a>
Background Image
When $_siteBackground is set, apply it as an inline style on <body>. The $_siteBackgroundMode value (cover, contain, or repeat) determines how the image is displayed.
Default behavior: When no appearance settings are configured (all empty), the theme renders exactly as before — text logo, no favicon, no background image, no color overrides, and all menu items visible. Themes should always handle the empty state gracefully.
Footer Builder
The admin can configure the footer layout via Settings → Appearance → Footer. The footer is divided into 1–4 columns, each with a configurable type. Your theme receives resolved footer data as global variables.
Variables
| Variable | Type | Description |
|---|---|---|
$_footerColumns | array | Resolved column data (see column types below) |
$_footerBottom | array | Bottom bar config: show_copyright, copyright_text, show_disclaimer, disclaimer_text, show_social |
$_socialLinks | array | Social URLs: ['twitter' => '...', 'instagram' => '...', ...] |
Column Types
Each entry in $_footerColumns has a type key and type-specific data:
| Type | Extra Keys | Description |
|---|---|---|
logo | logo, site_name, description | Site logo/name and description |
navigation | — | Renders main menu items (use $_menuItems) |
pages | pages (array of [id, title, slug]) | Selected CMS pages or all footer pages |
custom_links | links (array of [label, url]) | Custom links defined by admin |
badges | badges (array of [icon, label]) | Info badges with SVG icons |
text | content (HTML string) | Free-form HTML content |
social | links (filtered $_socialLinks) | Social media icon links |
Rendering Example
<footer class="footer footer__cols-<?= count($_footerColumns) ?>">
<div class="container">
<div class="footer__inner">
<?php foreach ($_footerColumns as $col): ?>
<div class="footer__col">
<?php if ($col['title']): ?><h4><?= htmlspecialchars($col['title']) ?></h4><?php endif; ?>
<?php switch ($col['type']):
case 'logo': ?>
<!-- logo/site_name/description -->
<?php break; case 'pages': ?>
<?php foreach ($col['pages'] as $pg): ?>
<a href="<?= url('/' . $pg['slug']) ?>"><?= htmlspecialchars($pg['title']) ?></a>
<?php endforeach; ?>
<?php break; case 'badges': ?>
<?php $icons = FooterConfig::badgeIcons();
foreach ($col['badges'] as $b):
if (isset($icons[$b['icon']])): ?>
<div class="footer__badge">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<?= $icons[$b['icon']]['svg'] ?>
</svg>
<span><?= htmlspecialchars($b['label']) ?></span>
</div>
<?php endif; endforeach; ?>
<?php break; ?>
<!-- ... other types -->
<?php endswitch; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</footer>
Badge Icons
Use FooterConfig::badgeIcons() to get the SVG data for each icon key: shield, upload, video, star, check, heart, globe, lock, zap, award.
Backward compat: $_footerPages and $_socialLinks are still passed for themes not yet using the footer builder. When no footer config exists in the database, the default layout (4 columns: logo, navigation, pages, badges) is used automatically.
Video Cards
TubePress provides admin-configurable video card settings via Settings → Video Cards. These settings control what elements appear on each card, which meta info is displayed, grid spacing, and border radius. Your theme receives these as global variables.
Card Variables
These variables are available in every template alongside the other global variables:
| Variable | Type | Default | Description |
|---|---|---|---|
$_cardShowDuration | bool | true | Show the duration badge on the thumbnail |
$_cardShowTitle | bool | true | Show the video title below the thumbnail |
$_cardMetaLeft | string | 'views' | Left meta info: views, likes, time, or none |
$_cardMetaRight | string | 'time' | Right meta info: views, likes, time, or none |
$_cardGridGap | int | 4 | Gap between cards in pixels (0–30) |
$_cardBorderRadius | int | 8 | Card border-radius in pixels (0–24) |
$_cardThumbnailHover | bool | true | Enable zoom effect on thumbnail hover |
$_cardTitleLines | int | 1 | Number of title lines: 1 (ellipsis) or 2 (line-clamp) |
CSS Custom Properties
The $_colorOverrides style tag also includes two card-specific CSS variables that you can use in your stylesheet:
:root {
--card-gap: 4px; /* from admin setting card_grid_gap */
--card-radius: 8px; /* from admin setting card_border_radius */
}
Use them in your grid and card styles:
.video-grid {
display: grid;
grid-template-columns: repeat(var(--grid-cols), 1fr);
gap: var(--card-gap, 4px);
}
.video-card {
border-radius: var(--card-radius, 8px);
overflow: hidden;
}
Body Classes
The layout adds conditional classes to <body> based on card settings:
| Class | When | Purpose |
|---|---|---|
no-thumb-hover | $_cardThumbnailHover === false | Disable thumbnail zoom on hover |
title-lines-1 | $_cardTitleLines === 1 | Single-line titles with ellipsis |
title-lines-2 | $_cardTitleLines === 2 | Two-line titles with line-clamp |
Use them to disable hover effects and control title wrapping in CSS:
/* Zoom on hover (default) */
.video-card:hover .video-card__thumb img {
transform: scale(1.05);
}
/* Disable when admin turns it off */
body.no-thumb-hover .video-card:hover .video-card__thumb img {
transform: none;
}
/* Single-line title (default) */
.video-card__title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Two-line clamp variant */
.video-card__title--clamp2 {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
Video Card Partial
The built-in Simply theme uses a partial template at templates/partials/video-card.php for all video cards. This ensures the exact same card is rendered everywhere: homepage, categories, tags, performers, studios, search results, and related videos.
The partial receives these pre-computed variables:
| Variable | Type | Description |
|---|---|---|
$thumb | string | Thumbnail URL (escaped) |
$title | string | Video title (escaped) |
$slug | string | Video slug (escaped) |
$duration | string | Formatted duration (e.g. 05:25) |
$showDuration | bool | From $_cardShowDuration |
$showTitle | bool | From $_cardShowTitle |
$titleClass | string | CSS class: video-card__title or video-card__title video-card__title--clamp2 |
$left | string | Left meta text (or empty) |
$right | string | Right meta text (or empty) |
The partial template
Here is the default templates/partials/video-card.php:
<article class="video-card">
<a href="/video/<?= $slug ?>" class="video-card__thumb">
<img src="<?= $thumb ?>" alt="<?= $title ?>" loading="lazy">
<?php if ($showDuration): ?>
<span class="video-card__duration"><?= $duration ?></span>
<?php endif; ?>
</a>
<?php if ($showTitle || $left !== '' || $right !== ''): ?>
<div class="video-card__info">
<?php if ($showTitle): ?>
<h3 class="<?= $titleClass ?>">
<a href="/video/<?= $slug ?>"><?= $title ?></a>
</h3>
<?php endif; ?>
<?php if ($left !== '' || $right !== ''): ?>
<div class="video-card__meta">
<span><?= $left ?></span>
<span><?= $right ?></span>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</article>
The helper function
The helper function prepares variables from the card settings and renders the partial:
function myThemeVideoCard(array $video): string
{
// Allow plugins to track impressions (e.g. CTR plugin)
HookSystem::doAction('video.card.before', $video);
global $_cardShowDuration, $_cardShowTitle, $_cardMetaLeft, $_cardMetaRight,
$_cardBorderRadius, $_cardTitleLines;
// Allow plugins to override entirely
$card = HookSystem::applyFilter('video.card.html', '', $video);
if ($card !== '') { return $card; }
$thumb = htmlspecialchars(VideoAsset::thumbUrl($video, true)); // prefer small for listings
$preview = htmlspecialchars(VideoAsset::previewUrl($video));
$metaValues = [
'views' => Format::number((int)($video['views'] ?? 0)) . ' views',
'likes' => Format::number((int)($video['likes'] ?? 0)) . ' likes',
'time' => Format::timeAgo($video['published_at'] ?? $video['created_at'] ?? ''),
'none' => '',
];
$data = [
'thumb' => $thumb,
'preview' => $preview,
'title' => htmlspecialchars($video['title']),
'slug' => htmlspecialchars($video['slug']),
'duration' => Format::duration((int)($video['duration'] ?? 0)),
'showDuration' => $_cardShowDuration ?? true,
'showTitle' => $_cardShowTitle ?? true,
'titleClass' => (($_cardTitleLines ?? 1) >= 2)
? 'video-card__title video-card__title--clamp2'
: 'video-card__title',
'left' => $metaValues[$_cardMetaLeft ?? 'views'] ?? '',
'right' => $metaValues[$_cardMetaRight ?? 'time'] ?? '',
];
// Render the partial as a string
$path = ThemeManager::templatePath('partials/video-card');
if ($path === '') { return ''; }
extract($data, EXTR_SKIP);
ob_start();
require $path;
return ob_get_clean();
}
Overriding the card: Since the partial is resolved via ThemeManager::templatePath(), you can override it in your own theme by creating templates/partials/video-card.php. Your version will be used instead of Simply's. This makes it easy to completely redesign the card without touching the helper function.
Default behavior: When no card settings are configured (fresh install), cards show duration, title (1 line), views on the left, time ago on the right, 4px gap, and 8px border radius. Themes should always provide sensible fallbacks using the ?? operator.
Layout requirement: For the --card-gap and --card-radius CSS variables to work, your layout must include <?= $_colorOverrides ?> after ThemeRenderer::renderCSS(). For body classes (no-thumb-hover, title-lines-N), include them on your <body> tag:
<body class="cols-<?= (int)($_videosPerRow ?? 4) ?><?php if (isset($_cardThumbnailHover) && !$_cardThumbnailHover): ?> no-thumb-hover<?php endif; ?> title-lines-<?= (int)($_cardTitleLines ?? 1) ?> <?= ThemeRenderer::bodyClass() ?>">
Listing Pages & Card Helpers (Theme-Driven)
TubePress allows themes to customize listing pages (categories, pornstars, studios) via hooks. Settings are theme-specific: when a theme is deactivated, its settings are cleaned up automatically via Setting::deleteByPrefix('simply_').
Hook Points
| Hook | Type | Purpose |
|---|---|---|
admin.subtabs.appearance | Filter | Add/remove sub-tabs in Appearance settings |
admin.appearance_extra.{section} | Action | Inject extra fields into Layout sub-tab sections (categories, performers, studios) |
admin.appearance_render.{sub} | Action | Render a full sub-tab when no core template exists |
admin.appearance_save.{sub} | Action | Save theme-specific settings for a sub-tab |
theme.deactivate | Action | Clean up when theme is deactivated |
Theme Variables (injected via theme.template_data filter)
| Variable | Type | Default | Description |
|---|---|---|---|
$_performersPerRow | int | 5 | Performers per row (4–7) |
$_studiosPerRow | int | 5 | Studios per row (4–7) |
$_darkModeEnabled | bool | true | Whether visitors can toggle dark/light mode |
$_darkModeDefault | string | 'light' | Default theme for first-time visitors (light or dark) |
$_topbarEnabled | bool | false | Whether the top bar is enabled |
$_topbarText | string | '' | Top bar free text |
$_topbarLinks | array | [] | Top bar links (each: {text, url, icon}) |
$_topbarBgColor | string | '' | Top bar background CSS color (hex or rgba) |
$_topbarTextColor | string | '' | Top bar text CSS color (hex or rgba) |
$_topbarHoverTextColor | string | '' | Top bar link hover text CSS color (hex or rgba) |
$_topbarDarkBgColor | string | '' | Top bar background color in dark mode (hex or rgba) |
$_topbarDarkTextColor | string | '' | Top bar text color in dark mode (hex or rgba) |
$_topbarDarkHoverTextColor | string | '' | Top bar hover color in dark mode (hex or rgba) |
$_topbarTextBold | bool | false | Bold announcement text |
$_topbarTextItalic | bool | false | Italic announcement text |
$_topbarLinkBold | bool | false | Bold link text |
$_topbarLinkItalic | bool | false | Italic link text |
$_topbarLinkUnderline | bool | false | Underline link text |
$_topbarLinkHoverUnderline | bool | true | Underline links on hover |
Card Helper Functions (Simply theme)
The Simply theme provides three helper functions for listing cards:
// Category card (overlay style with thumbnail)
echo simplyCategoryCard($category);
// Performer card (circle style with name, video count, views)
echo simplyPerformerCard($performer);
// Studio card (circle style, same design as performer)
echo simplyStudioCard($studio);
Top Bar (Sur-Header)
The Simply theme provides a configurable top bar above the main header. It can display free text, partner links, or both. Configure it in Appearance → Header.
- Enable/Disable toggle to show or hide the bar
- Content:
- Text: free text displayed on the left
- Links: repeater for icon + label + URL (FontAwesome icon picker, opens in new tab)
- Text formatting: bold/italic toggles for announcement text; bold/italic/underline toggles for links; hover underline toggle
- Appearance (Light / Dark mode tabs):
- Light mode colors: background, text, and hover text colors with opacity (HSVA picker)
- Dark mode colors: separate background, text, and hover text colors for
[data-theme="dark"] - Live preview strip showing normal text + hover state for each mode
Content settings: simply_topbar_enabled, simply_topbar_text, simply_topbar_links (JSON).
Text formatting settings: simply_topbar_text_bold, simply_topbar_text_italic, simply_topbar_link_bold, simply_topbar_link_italic, simply_topbar_link_underline, simply_topbar_link_hover_underline.
Light mode color settings: simply_topbar_bg_color, simply_topbar_bg_opacity, simply_topbar_text_color, simply_topbar_text_opacity, simply_topbar_hover_text_color, simply_topbar_hover_text_opacity.
Dark mode color settings: simply_topbar_dark_bg_color, simply_topbar_dark_bg_opacity, simply_topbar_dark_text_color, simply_topbar_dark_text_opacity, simply_topbar_dark_hover_text_color, simply_topbar_dark_hover_text_opacity.
Dark Mode
The Simply theme includes a built-in dark/light mode system. Configure it in Appearance → Header (Dark Mode card).
- Enable toggle: when enabled, visitors see a sun/moon toggle button to switch between light and dark themes. When disabled, the site uses the default theme only.
- Default theme: choose
lightordarkas the default for first-time visitors. Returning visitors keep their saved preference inlocalStorage. - FOUC prevention: an inline
<script>in<head>setsdata-themeon<html>before first paint, reading fromlocalStoragewith fallback to the admin-configured default. - CSS variables: dark mode styles are applied via
[data-theme="dark"]selectors in the theme stylesheet.
Settings: simply_dark_mode_enabled (1/0), simply_dark_mode_default (light or dark).
Implementing in Your Theme
To add listing customization in a custom theme:
- Register a
theme.template_datafilter to inject your variables - Register
admin.appearance_extra.*actions to render pickers in admin - Use
admin.subtabs.appearancefilter to add custom sub-tabs - Register an
admin.appearance_save.layoutaction to save your settings - Register a
theme.deactivateaction to clean up on deactivation
Translation System
TubePress includes a full multilingual system supporting 30 languages. Translation files live in lang/{code}.php and return a flat array of key-value pairs.
Using Translations in Themes
The global __() helper function translates a key:
<!-- Simple translation -->
<h1><?= __('home') ?></h1>
<!-- With placeholder replacements -->
<p><?= __('video_count_plural', [':count' => $total]) ?></p>
<!-- Conditional singular/plural -->
<p><?= $total === 1 ? __('video_count', [':count' => 1]) : __('video_count_plural', [':count' => $total]) ?></p>
Available Translation Keys
Translation keys are grouped into categories:
| Category | Example Keys |
|---|---|
| Navigation | home, categories, tags, pornstars, studios |
| Auth | login, sign_up, logout, password |
| Video | views, show_more, related_videos, add_to_favorites |
| Comments | comments_count, add_a_comment, reply_to_comment |
| Time | time_seconds_ago, time_minutes_ago, time_hours_ago |
| Empty States | no_videos, no_categories, no_results_for |
There are 130+ translation keys covering every frontend string. Check lang/en.php for the complete list.
Language Switcher
The active language is detected automatically from: ?_lang= parameter → cookie → browser Accept-Language → DB default. Use the Translator API to build a language switcher:
<?php foreach (Translator::available() as $lang): ?>
<a href="?_lang=<?= $lang['code'] ?>"
class="<?= Translator::locale() === $lang['code'] ? 'active' : '' ?>">
<?= htmlspecialchars($lang['native_name']) ?>
</a>
<?php endforeach; ?>
Custom Overrides
Admins can customize any translation string directly from the admin panel at Languages → Edit Translations. Overrides are stored in the database and take priority over file-based translations:
Priority chain: DB override → language file value → English fallback → raw key.
Overrides survive CMS updates. The original translation files are never modified. Click "Reset" in the admin to remove an override and fall back to the file default.
How It Works
- Table:
translation_overrideswith columnslang_code,translation_key,value - Loading: Overrides are loaded once at boot via a single indexed query per request
- Save: Only changed values are stored. Values matching the file default are automatically deleted
- Reset: Deleting a row instantly reverts to the file default
Translator API
The Translator class provides static methods for working with translations:
| Method | Description |
|---|---|
Translator::get($key, $replacements) | Get translated string with optional placeholder replacements |
Translator::locale() | Current locale code (e.g. 'fr') |
Translator::setLocale($code) | Switch locale and reload strings + overrides |
Translator::available() | Array of enabled languages from DB |
Translator::isRtl() | Whether current locale is RTL (Arabic, Persian, Hebrew) |
Translator::direction() | Returns 'rtl' or 'ltr' |
Translator::jsTranslations() | JSON-encoded subset of keys for frontend JS |
Translator::getFileStrings($code) | Raw file strings for a language (no overrides) |
Translator::getOverrides($code) | DB overrides only for a language |
RTL Support
Set the dir attribute on your <html> tag for RTL languages:
<html lang="<?= Translator::locale() ?>" dir="<?= Translator::direction() ?>">
Captcha (Google reCAPTCHA)
TubePress includes built-in support for Google reCAPTCHA v2 (checkbox) and v3 (invisible). The admin configures it under Settings → Advanced → Google reCAPTCHA.
Captcha API
All methods are static. Call them with Captcha::methodName().
| Method | Return | Description |
|---|---|---|
Captcha::provider() | string | Active provider: 'none', 'v2', or 'v3' |
Captcha::siteKey() | string | The reCAPTCHA site key (empty if not configured) |
Captcha::theme() | string | Widget theme: 'light' or 'dark' |
Captcha::isEnabled($action) | bool | Check if captcha is active for an action ('register' or 'comment'). Returns false if provider is none or keys are missing. |
Captcha::verify($action) | bool | Server-side verification. Returns true if captcha is not enabled for this action. For v3, checks score ≥ 0.5. |
Captcha::renderWidget($action) | string | Renders the captcha HTML for a form. For v2: a <div class="g-recaptcha"> with data-theme. For v3: a hidden input + auto-execute script. |
Captcha::scriptTag() | string | Returns the <script> tag to load the Google reCAPTCHA API. Include once in the page head. |
Usage in Templates
To add a captcha widget to any form:
<!-- In your <head> or layout.php -->
<?php if ($_captchaProvider !== 'none' && $_captchaSiteKey !== ''): ?>
<?= Captcha::scriptTag() ?>
<?php endif; ?>
<!-- In a form (e.g. comment form) -->
<form method="POST" action="/video/<?= $video['id'] ?>/comment">
<textarea name="body"></textarea>
<?= Captcha::renderWidget('comment') ?>
<button type="submit">Submit</button>
</form>
Server-side Verification
In your controller, call Captcha::verify() before processing the form. It automatically skips verification when captcha is disabled for the given action:
if (!Captcha::verify('comment')) {
// Show error: captcha failed
return;
}
// Process the comment...
Guest Comments
When registration is disabled in admin settings, visitors can post comments without logging in. The comment form should include an optional guest_name field. If registration is enabled, login is required to comment.
Template variables to check:
$_registrationEnabled— iffalse, show guest comment form$_commentsEnabled— iffalse, hide comments entirely$_user— if not null, user is logged in (show authenticated form)
Tip: The global variables $_captchaProvider, $_captchaSiteKey, $_captchaOnRegister, and $_captchaOnComment are available in all templates for conditional rendering.
SpamGuard (Anti-Spam)
TubePress includes a built-in SpamGuard system that protects comment forms from spam and bots. It works independently of reCAPTCHA. All layers are individually configurable in Settings → Advanced → Comment Anti-Spam.
Protection Layers
| Layer | Setting key | How it works | Default |
|---|---|---|---|
| Honeypot | spam_honeypot | Hidden website field off-screen. Bots auto-fill it; humans never see it. | Enabled |
| Time gate | spam_timegate | HMAC-signed timestamp. Submissions faster than spam_timegate_seconds are rejected. | Enabled, 3 sec |
| Rate limiting | spam_ratelimit | Max spam_ratelimit_hour per hour and spam_ratelimit_day per day per IP. | Enabled, 5/h, 30/day |
| Duplicate detection | spam_duplicate | SHA-256 hash of body + IP check within spam_duplicate_window minutes. | Enabled, 60 min |
| Link filter | spam_links | Block comments exceeding spam_max_links URLs. Set to 0 to reject all links. | Enabled, 0 links |
| Length check | spam_length | Enforce spam_min_length – spam_max_length characters. | Enabled, 2–5000 |
SpamGuard API
| Method | Return | Description |
|---|---|---|
SpamGuard::formFields() | string | Returns honeypot + time gate hidden HTML fields. Include inside every comment <form>. |
SpamGuard::validate($body, $ip, $videoId, $context) | string|null | Runs all checks. $context defaults to 'comment'; pass 'report' for report form submissions. Returns null if clean, or an error translation key. |
SpamGuard::ip() | string | Returns the client IP address. |
SpamGuard::bodyHash($body) | string | SHA-256 hash of the comment body (used for duplicate detection). |
Usage in Themes
Include SpamGuard::formFields() inside your comment form, right after the CSRF field:
<form method="POST" action="/video/<?= $video['id'] ?>/comment">
<?= Router::csrfField() ?>
<?= SpamGuard::formFields() ?>
<textarea name="body"></textarea>
<button type="submit">Submit</button>
</form>
Important: SpamGuard validation is performed automatically by the core comment controller. Themes only need to include SpamGuard::formFields() in comment forms. No additional configuration or server-side code is needed in the theme.
Error Translation Keys
When SpamGuard rejects a comment, the error message is shown via the translation system. All keys are available in every language:
| Key | Shown when |
|---|---|
spam_detected | Honeypot triggered or time gate signature tampered |
comment_too_fast | Submitted faster than configured minimum seconds |
comment_too_short | Body below configured minimum length |
comment_too_long | Body exceeds configured maximum length |
comment_too_many_links | URLs exceed configured max links (default 0 = no links) |
comment_rate_limited | IP exceeded configured rate limit |
comment_duplicate | Identical comment from same IP within configured window |
Admin Settings
All settings are in Settings → Advanced → Comment Anti-Spam. Each layer has a checkbox to enable/disable it, plus numeric inputs for thresholds. Changes take effect immediately.
| Setting | Type | Default | Description |
|---|---|---|---|
spam_honeypot | bool | 1 | Enable honeypot hidden field |
spam_timegate | bool | 1 | Enable time gate check |
spam_timegate_seconds | int | 3 | Minimum seconds before submission (1–30) |
spam_ratelimit | bool | 1 | Enable IP rate limiting |
spam_ratelimit_hour | int | 5 | Max comments per hour per IP (1–100) |
spam_ratelimit_day | int | 30 | Max comments per day per IP (1–500) |
spam_duplicate | bool | 1 | Enable duplicate detection |
spam_duplicate_window | int | 60 | Duplicate check window in minutes (1–1440) |
spam_links | bool | 1 | Enable link filter |
spam_max_links | int | 0 | Max allowed URLs per comment (0 = none) |
spam_length | bool | 1 | Enable length validation |
spam_min_length | int | 2 | Minimum comment length (1–500) |
spam_max_length | int | 5000 | Maximum comment length (10–50000) |
Report System
TubePress includes a video reporting system that lets visitors flag content for review. Reports are managed through the admin inbox with full AJAX support.
Front-End Report Form
The report modal is displayed on the video watch page and includes:
- Reason dropdown —
<select>with predefined reasons (illegal, copyright, spam, broken, underage, other) - Email field — optional
<input type="email">for reporter contact - Details textarea — 4-row textarea for additional details (max 1000 chars)
- SpamGuard fields — honeypot + time gate hidden inputs via
SpamGuard::formFields()
Report API Endpoint
| Method | URL | Body (JSON) | Response |
|---|---|---|---|
POST |
/video/{id}/report |
reason, details, reporter_email, _csrf_token, _sg_ts, _sg_h, website |
JSON with success or error + message |
SpamGuard Integration
Reports use the same SpamGuard system as comments, with a dedicated 'report' context:
// In the report controller:
$spamError = SpamGuard::validate($details ?: $reason, $ip, $videoId, 'report');
// Duplicate detection checks the reports table (same IP + same video)
// Rate limiting uses separate 'report' keys from 'comment' keys
Admin Report Management
Reports are managed in Inbox → Reports. The admin interface supports:
- Modal view — click “View” to open a modal with full report details, reporter email, status controls
- AJAX status update — change status (pending/reviewed/resolved/dismissed) + admin note without page reload
- Delete video — red button in modal deletes the video and resolves all reports for it
Admin Report API
| Method | URL | Description |
|---|---|---|
GET | /admin/reports/{id}/json | Returns report data as JSON (for modal) |
POST | /admin/reports/{id}/status | Update status + admin note. Send _ajax=1 for JSON response. |
POST | /admin/reports/{id}/delete-video | Deletes the reported video and resolves all reports for it. |
Server Health Check
TubePress includes a comprehensive server health check in Settings → Maintenance → Server Health. It provides a WordPress-style diagnostics page that checks your server configuration.
Check Categories
| Category | Checks |
|---|---|
| PHP | Version (≥ 8.1), required extensions (pdo, pdo_mysql, mbstring, json, fileinfo, curl, openssl, zip, xml), image library (GD/Imagick) |
| PHP Config | memory_limit (≥ 128M), max_execution_time (≥ 30), upload_max_filesize (≥ 50M), post_max_size (≥ 50M), max_input_vars (≥ 1000), allow_url_fopen, file_uploads |
| Permissions | Writable directories: config/, storage/, public/uploads/, storage/imports/ |
| Server | Web server detection (Nginx/Apache) |
| Database | Connection status, MySQL version (≥ 5.7), charset (utf8mb4) |
| Security | HTTPS enabled, display_errors off, expose_php off |
| Performance | OPcache enabled, realpath_cache_size (≥ 4K) |
Status Indicators
- ✓ Pass — Check meets or exceeds the recommended value
- ⚠ Warning — Check is below recommended but not critical
- ✗ Fail — Critical issue that should be fixed
The overall status banner shows green (all pass), yellow (warnings), or red (failures) with counts for each status.
Note: Each failed or warning check includes a recommended fix command. Common fixes involve editing php.ini or adjusting file permissions.
Hooks & Filters
TubePress uses a WordPress-like hook system. Themes and plugins can register actions (side effects) and filters (data transformations).
Actions
Actions run code at specific points without modifying data.
// Register an action (priority 10 = default)
HookSystem::addAction('head.meta', function () {
echo '<meta property="og:site_name" content="My Site">';
}, 10);
// Fire an action in your template
HookSystem::doAction('my_custom_hook');
// Check if an action exists
if (HookSystem::hasAction('head.meta')) { ... }
// Remove all callbacks for an action
HookSystem::removeAction('head.meta');
Built-in action hooks
| Hook | Fires |
|---|---|
head.meta | In <head>, after CSS — inject meta tags, fonts, custom styles |
footer.scripts | Before </body>, after JS — inject analytics, custom scripts |
video.card.before | Before each video card renders — plugins can track impressions |
render.complete | After page render completes — post-render tasks |
routes.registered | After all routes are defined — register custom routes |
Filters
Filters transform data and must return the modified value.
// Modify template data before rendering
HookSystem::addFilter('theme.template_data', function (array $data, string $tpl) {
$data['myCustomVar'] = 'Hello';
return $data;
});
// Override a video card HTML
HookSystem::addFilter('video.card.html', function (string $html, array $video) {
return '<div class="my-card">'.htmlspecialchars($video['title']).'</div>';
});
// Check if a filter exists
if (HookSystem::hasFilter('video.card.html')) { ... }
// Remove all callbacks for a filter
HookSystem::removeFilter('video.card.html');
Built-in filter hooks
| Hook | Args | Description |
|---|---|---|
theme.template_data | (array $data, string $template) | Modify variables before a template renders. Must return $data. |
head.css | (string $html) | Modify the CSS <link> tags HTML output |
video.card.html | (string $html, array $video) | Override the HTML of a video card |
video.sort_options | (array $allowedSorts) | Register additional whitelisted sort options. Return associative array ['key' => 'SQL clause']. |
video.similar | (null, int $id, int $limit) | Override similar videos query. Return array to replace default. |
video.recommended | (null, int $excludeId, array $excludeIds, int $limit) | Override recommended videos query |
video.trending | (null, int $limit) | Override trending videos query |
category.listing | (null, int $limit, int $offset) | Override category listing query |
performer.listing | (null, string $search, int $limit, int $offset) | Override performer listing query |
studio.listing | (null, string $search, int $limit, int $offset) | Override studio listing query |
front.default_sort | (string $default, string $context) | Change default sort order on entity pages |
video.should_increment_views | (bool $should, array $video) | Control whether a view should be counted |
Plugin System
TubePress supports WordPress-style plugins that extend the CMS without modifying core files. Plugins live in the plugins/ directory and are managed from Admin → Plugins.
How Plugins Work
- Activate: go to Admin → Plugins → click Activate. The plugin's
boot()method registers its hooks. - Deactivate: click Deactivate. All hooks are removed, the CMS falls back to its default behavior.
- No core modification: plugins use HookSystem filters and actions to override queries, templates, and behavior.
Plugin File Structure
plugin.json
{
"name": "My Plugin",
"description": "What this plugin does.",
"version": "1.0.0",
"author": "Your Name",
"requires": "1.0.0"
}
plugin.php
Must return an instance of PluginInterface:
<?php
declare(strict_types=1);
return new class implements PluginInterface {
public function boot(): void {
// Register hooks — runs when plugin is active
HookSystem::addFilter('video.sort_options', function ($sorts) {
$sorts['trending'] = 'v.my_score DESC';
return $sorts;
});
// Register a cron task (every 30 minutes)
CronManager::register('my_task', 1800, function () {
Database::query('UPDATE videos SET my_score = ...');
});
}
public function activate(): void {
// Run on activation — create DB columns, tables, etc.
}
public function deactivate(): void {
// Run on deactivation — cleanup if needed
}
public function install(): void { $this->activate(); }
public function uninstall(): void { /* drop columns/tables */ }
public function info(): array {
return json_decode(file_get_contents(__DIR__ . '/plugin.json'), true) ?? [];
}
};
Available Hook Points
Plugins can use all the filters and actions listed in the Hooks & Filters section to override video queries, listing behavior, sort order, template data, and more. Key hooks for content plugins:
| Hook | Type | Use Case |
|---|---|---|
video.sort_options | Filter | Custom sort algorithms (whitelist-based) |
video.similar / video.recommended / video.trending | Filter | Override video queries |
category.listing / performer.listing / studio.listing | Filter | Override entity listings |
front.default_sort | Filter | Change default sort on entity pages |
video.should_increment_views | Filter | Bot filtering, view control |
video.card.before | Action | Track impressions per video card |
theme.template_data | Filter | Inject custom template variables |
footer.scripts | Filter | Inject scripts or run end-of-page logic |
Example: CTR Ranking Plugin
The official CTR Ranking plugin demonstrates the full plugin pattern. When activated, it:
- Adds
impressionsandctr_scorecolumns to the videos table - Tracks thumbnail impressions via
video.card.before - Overrides sort/trending/similar/recommended queries via filters
- Sorts performers, studios, and categories by total CTR with best thumbnails (deduplicated — no two categories share the same thumbnail)
- Registers a cron task to recalculate scores every 10 minutes
- Filters bot traffic from views and impressions
When deactivated, everything falls back to the default behavior (views, name sort, random recommendations). No CTR code runs in core — it is entirely plugin-driven.
Safe fallbacks: Plugins may add extra fields to entity arrays (total_ctr, best_video_folder). Always use the ?? operator in templates:
$img = !empty($performer['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($performer['best_video_folder']) : ($performer['photo'] ?? '');
Thumbnail Deduplication System
On listing pages (/categories, /pornstars, /studios), each entity displays a thumbnail from its best-performing video. Since multiple entities can share the same top video, a deduplication algorithm ensures no two entities display the same thumbnail.
Priority Chain
- Manual upload always wins. If an admin has uploaded a custom thumbnail (
thumbnailfor categories,photofor performers,logofor studios), that image is always used — the deduplication system skips these entities entirely. - Best video by ranking. For entities without a manual upload, the system picks the video with the highest CTR score (when the CTR plugin is active) or the most views (core fallback). If that video's thumbnail is already claimed by a higher-priority entity, it moves to the next-best video, and so on.
How It Works
The method VideoAsset::assignUniqueThumbFolders() runs one single SQL query to fetch all candidate video folders for the entities on the current page, ranked by score. It then iterates through the entities in display order (highest CTR / sort order first) and greedily assigns the best available (unclaimed) folder to each.
// Parameters:
VideoAsset::assignUniqueThumbFolders(
&$items, // Array of entities (modified in-place, adds 'best_video_folder' key)
$joinTable, // Junction table: 'video_categories', 'video_performers', or 'video_studios'
$fkColumn, // FK column: 'category_id', 'performer_id', or 'studio_id'
$manualField, // Manual upload field: 'thumbnail', 'photo', or 'logo'
$orderBy // Optional: 'v.ctr_score DESC, v.views DESC' (CTR) or 'v.views DESC' (core)
);
Where It Runs
| Context | Location | Order By |
|---|---|---|
| Core (CTR inactive) | Category::all(), Performer::all(), Studio::all() | v.views DESC |
| CTR plugin (active) | category.listing, performer.listing, studio.listing hooks | v.ctr_score DESC, v.views DESC |
Thumbnail sizes: Category, performer, and studio cards use the full-size thumbnail (thumb.jpg) for visual quality. Video cards in grids use thumb_small.jpg for faster loading. The video player poster always uses the full-size thumbnail.
CronManager (Pseudo-Cron)
TubePress includes a built-in WordPress-style pseudo-cron system that runs scheduled tasks automatically — no system crontab required. It works on any hosting (Apache, Nginx, shared hosting) without any configuration.
How It Works
- At the end of every page render (
ThemeRenderer::render()),CronManager::run()is called automatically - The manager checks each registered task: has enough time passed since the last run?
- If a task is due, it acquires an atomic lock via a SQL
UPDATE ... WHERE value = ?query — this prevents concurrent execution even under heavy traffic - The task callback runs, and the timestamp is stored in the
settingstable
Registering Tasks
The CronManager has no built-in tasks — plugins register their own. Use CronManager::register() in your plugin's boot() method:
// Register a task that runs every 10 minutes (600 seconds)
CronManager::register('my_recalc', 600, function () {
Database::query('UPDATE videos SET score = ...');
});
// Register a task that runs every hour
CronManager::register('cleanup', 3600, function () {
Database::query('DELETE FROM sessions WHERE expires_at < NOW()');
});
| Method | Args | Description |
|---|---|---|
CronManager::register() | string $name, int $interval, callable $callback | Register a recurring task. $name must be unique. $interval is in seconds. |
CronManager::run() | (none) | Execute all due tasks. Called automatically by ThemeRenderer — no manual call needed. |
Resilience
- Server crash / restart: Timestamps are stored in the database (not in memory). After restart, the next page load picks up where it left off.
- No visitors: Tasks only run when someone visits the site. If no one visits for an hour, tasks run on the next visit.
- Concurrent requests: The atomic lock prevents two requests from running the same task simultaneously.
- Task failure: Errors are caught and logged to
storage/logs/error.log. The task will retry on the next interval.
No configuration needed: The pseudo-cron works out of the box. Theme developers don't need to do anything — CronManager::run() is called automatically after every ThemeRenderer::render() call. Plugins simply call CronManager::register() during boot() and the rest is handled by the core.
AJAX Endpoints
These POST-only routes return JSON or redirect. Use them with JavaScript fetch calls from your theme:
| Endpoint | Method | Description | Response |
|---|---|---|---|
/video/{id}/like | POST | Like a video (authenticated) | JSON: { likes, dislikes, userVote } |
/video/{id}/dislike | POST | Dislike a video (authenticated) | JSON: { likes, dislikes, userVote } |
/video/{id}/comment | POST | Post a comment. Body: body, optional parent_id, optional guest_name. Requires login if registration is enabled; otherwise allows guest comments. | Redirect to video page |
/favorite/{id}/toggle | POST | Toggle favorite status (authenticated) | JSON: { favorited: true/false } |
/history/clear | POST | Clear watch history (authenticated) | Redirect to history page |
Example: Call the like endpoint from JavaScript:
fetch(`/video/${videoId}/like`, { method: 'POST' })
.then(r => r.json())
.then(data => {
// data.likes, data.dislikes, data.userVote
});
CSS Variables
The default Simply theme defines CSS custom properties on :root. Override them in your own stylesheet:
:root {
--primary: #2563eb; --primary-dark: #1d4ed8;
--primary-light: #dbeafe; --secondary: #7c3aed;
--bg: #ffffff; --bg-surface: #f8fafc;
--bg-card: #ffffff; --bg-dark: #0f172a;
--text: #1e293b; --text-muted: #64748b;
--text-light: #94a3b8; --border: #e2e8f0;
--success: #059669; --danger: #dc2626;
--radius: 8px; --radius-lg: 12px;
--max-width: 1320px; --header-height: 60px;
--grid-cols: 4; /* controlled by body.cols-N */
--card-gap: 4px; /* admin: Video Cards → gap */
--card-radius: 8px; /* admin: Video Cards → border radius */
}
Responsive Grid System
The video grid respects the admin “Videos per row” setting (4, 5, or 6) via a CSS class on <body>.
Your layout must include this class:
<body class="cols-<?= (int)($_videosPerRow ?? 4) ?> <?= ThemeRenderer::bodyClass() ?>">
Then define grid columns and responsive breakpoints in your CSS:
.video-grid {
display: grid;
grid-template-columns: repeat(var(--grid-cols), 1fr);
gap: 10px;
}
body.cols-4 { --grid-cols: 4; }
body.cols-5 { --grid-cols: 5; }
body.cols-6 { --grid-cols: 6; }
/* Responsive — only reduce, never increase */
@media (max-width: 1400px) { body.cols-6 { --grid-cols: 5; } }
@media (max-width: 1200px) { body.cols-5, body.cols-6 { --grid-cols: 4; } }
@media (max-width: 1024px) { body.cols-4, body.cols-5, body.cols-6 { --grid-cols: 3; } }
@media (max-width: 768px) { body.cols-4, body.cols-5, body.cols-6 { --grid-cols: 2; } }
How it works: When the admin selects 5 columns, <body class="cols-5"> sets --grid-cols: 5. On screens narrower than 1200px, the media query targets body.cols-5 specifically to reduce it to 4. This approach never increases columns beyond the admin's choice.
Listing Grids
All grid column settings are core CMS settings — they are stored in the settings table, managed by SettingController, injected by ThemeRenderer, and persist across theme changes. They are configured in Admin > Settings > Appearance > Layout.
| Setting Key | Label | CSS Variable | Default | Range |
|---|---|---|---|---|
videos_per_row | Videos per row | --grid-cols | 5 | 4 – 6 |
categories_per_row | Categories per row | --cat-cols | 5 | 3 – 6 |
performers_per_row | Pornstars per row | --perf-cols | 5 | 4 – 7 |
studios_per_row | Studios per row | --studio-cols | 5 | 4 – 7 |
How it works
- Save:
SettingController::update()validates and stores each value in thesettingstable. - Inject:
ThemeRenderer::render()reads them viaSetting::get()and passes$_videosPerRow,$_categoriesPerRow,$_performersPerRow,$_studiosPerRowto every template. - Render: Each listing template outputs an inline
<style>that sets the CSS variable, overriding the theme default.
/* Theme CSS grid definitions */
.video-grid { grid-template-columns: repeat(var(--grid-cols), 1fr); }
.category-grid { grid-template-columns: repeat(var(--cat-cols), 1fr); }
.performer-grid { grid-template-columns: repeat(var(--perf-cols), 1fr); }
.studio-grid { grid-template-columns: repeat(var(--studio-cols), 1fr); }
/* Responsive overrides reduce columns on smaller screens */
@media (max-width: 1024px) {
.category-grid { --cat-cols: 3; }
.performer-grid { --perf-cols: 3; }
.studio-grid { --studio-cols: 3; }
}
@media (max-width: 768px) {
.category-grid { --cat-cols: 2; }
.performer-grid { --perf-cols: 2; }
.studio-grid { --studio-cols: 2; }
}
Core, not theme: These settings belong to the CMS core. Switching or deactivating a theme does not affect them. The theme only provides the CSS classes and responsive breakpoints that consume the variables.
Partials
Partials are reusable template fragments stored in templates/partials/:
<!-- Include a partial from any template -->
<?php ThemeRenderer::partial('video-card', ['video' => $video]); ?>
<!-- templates/partials/video-card.php -->
<article class="video-card">
<a href="/video/<?= htmlspecialchars($video['slug']) ?>">
<img src="<?= htmlspecialchars(VideoAsset::thumbUrl($video)) ?>"
alt="<?= htmlspecialchars($video['title']) ?>" loading="lazy">
</a>
<h3><?= htmlspecialchars($video['title']) ?></h3>
</article>
How partials resolve: ThemeRenderer::partial('video-card', $data) looks for templates/partials/video-card.php in the active theme first, then falls back to the Simply theme. The $data array is extracted into local variables inside the partial.
Timezone Setting
TubePress includes a global timezone setting in Admin > Settings > General. When configured, all dates and times throughout the site use this timezone.
The timezone is applied at boot time in App::boot() via date_default_timezone_set(). The default is UTC.
// How it works internally:
$tz = Setting::get('timezone', 'UTC');
if (in_array($tz, DateTimeZone::listIdentifiers(), true)) {
date_default_timezone_set($tz);
}
Theme developers don’t need to do anything special — standard PHP date functions like date() and strtotime() will automatically use the configured timezone.
Email System (Mailer)
TubePress includes a built-in Mailer helper class (src/Helpers/Mailer.php) that provides centralized email sending with a modern HTML template.
Configuration
Email settings are configured in Admin > Settings > Advanced:
- PHP mail() — Default method, works on most hosting without configuration
- SMTP — For reliable delivery via external mail servers (Gmail, SendGrid, etc.)
SMTP settings: smtp_host, smtp_port, smtp_encryption (none/tls/ssl), smtp_user, smtp_pass, smtp_from_email, smtp_from_name.
A “Send Test Email” button in the admin panel lets you verify your configuration instantly.
API
// Send an email (wraps body in HTML template automatically)
Mailer::send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool
// Send a test email and return result
Mailer::test(string $to): array // ['success' => bool, 'error' => ?string]
HTML Template
All emails are wrapped in a responsive HTML template with:
- Light gray background (#f4f4f7) with white 600px card
- Header with site name and primary color accent
- Clean body area — pass any HTML as
$htmlBody - Footer with “Sent by {site_name}” and year
- All inline CSS for maximum email client compatibility
SMTP Implementation
The SMTP client uses raw PHP sockets (stream_socket_client) with STARTTLS/SSL support and AUTH LOGIN. No external dependencies (no PHPMailer, no SwiftMailer).
Settings keys
mail_method — 'php' (default) or 'smtp'
smtp_host — SMTP server hostname
smtp_port — Port number (default 587)
smtp_encryption — 'none', 'tls', or 'ssl'
smtp_user — SMTP username
smtp_pass — SMTP password
smtp_from_email — Sender email address
smtp_from_name — Sender display name (defaults to site_name)
Contact Form System
Pages in TubePress can optionally include a built-in contact form. This is controlled by the has_contact_form column on the pages table (toggle in the page editor sidebar).
How it works
- Admin: Edit any page → check “Show contact form” in the Publish card
- Front-end: The theme’s
page.phprenders a two-column layout: form on the left, page body on the right (stacks on mobile). If the page has no body content, the form is centered. - Submission:
POST /contact-submitis handled byFrontContactController - Storage: Messages are saved in the
contact_messagestable - Email notification: An HTML email is sent via
Mailer::send()to thecontact_emailsetting (non-fatal if it fails)
Flow
User submits form
→ CSRF check
→ Honeypot check (hidden field)
→ Rate limit check (3/hour, 10/day per IP)
→ Input validation & sanitization
→ CAPTCHA verification (if enabled)
→ Save to contact_messages table
→ Send HTML notification via Mailer::send()
→ Flash success message & redirect back
Security
- CSRF: Standard
Router::verifyCsrf()protection - Honeypot: Hidden
website_urlfield — if filled, silently swallows the submission - Rate limiting: 3 messages/hour, 10/day per IP via
RateLimiter::throttle() - CAPTCHA: Optional reCAPTCHA v2/v3 — enable “Contact form” in Admin > Settings > Security
- Sanitization: All inputs sanitized via
Sanitizer::text(), length limits enforced
contact_messages table
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
page_id INT UNSIGNED DEFAULT NULL
name VARCHAR(255) NOT NULL
email VARCHAR(255) NOT NULL
subject VARCHAR(500) NOT NULL
message TEXT NOT NULL
ip_address VARCHAR(45) DEFAULT NULL
is_read TINYINT(1) NOT NULL DEFAULT 0
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ContactMessage model
ContactMessage::all(int $limit, int $offset): array
ContactMessage::find(int $id): ?array
ContactMessage::count(): int
ContactMessage::countUnread(): int
ContactMessage::create(array $data): int
ContactMessage::markRead(int $id): void
ContactMessage::delete(int $id): void
Settings
contact_email— Email address that receives contact form submissions (General settings)captcha_on_contact— Enable CAPTCHA on contact form (Security settings)
Theme integration
In your page.php template, check $page['has_contact_form'] and render different layouts:
<?php
$hasContact = !empty($page['has_contact_form']);
$hasBody = trim($page['body'] ?? '') !== '';
?>
<?php if ($hasContact && $hasBody): ?>
<!-- Two-column: form left, body right -->
<div class="page-contact-layout">
<div class="page-contact-layout__form">
<form method="POST" action="/contact-submit">
<?= Router::csrfField() ?>
<div style="display:none"><input type="text" name="website_url" tabindex="-1"></div>
<!-- name, email, subject, message inputs -->
<?= Captcha::renderWidget('contact') ?>
<button type="submit">Send</button>
</form>
</div>
<div class="page-contact-layout__body rich-content">
<?= $page['body'] ?>
</div>
</div>
<?php elseif ($hasContact): ?>
<!-- Centered form (no body) -->
<div class="page-contact-centered">
<!-- same form -->
</div>
<?php endif; ?>
Required CSS classes
.page-contact-layout — Two-column grid (1fr 1fr)
.page-contact-layout__form — Sticky form column
.page-contact-layout__body — Body column with left border
.page-contact-centered — Centered form (max-width 640px)
.contact-form — Flex column form container
.contact-form__grid — 2-col grid for name/email row
.contact-form__group — Individual field wrapper
.contact-form__label — Field label
.contact-form__input — Text/email inputs
.contact-form__textarea — Message textarea
.contact-form__submit — Submit button
.contact-flash — Flash message container
.contact-flash--success — Green success variant
.contact-flash--error — Red error variant
Admin Messages Panel
Contact form submissions are viewable in the admin panel under Messages (sidebar, after Comments). Features:
- Paginated list of all messages, newest first
- Unread/read status with visual indicators and sidebar badge count
- Click to view full message — automatically marked as read
- “Reply by Email” button opens the user’s mail client
- Delete individual messages
Admin routes:
GET /admin/messages — Paginated message list
GET /admin/messages/{id} — View message (marks as read)
POST /admin/messages/{id}/delete — Delete message
Password Reset System
TubePress includes a complete token-based password reset flow for front-end users.
How it works
- User clicks “Forgot your password?” in the login modal
POST /forgot-password→FrontAuthController::forgotPassword()- If the email exists, a 64-char random token is generated and stored with a 1-hour expiry
- A reset email is sent via
Mailer::send()with a link to/reset-password?token=xxx - Always responds with a generic success message (prevents email enumeration)
- User clicks the link →
GET /reset-passwordvalidates the token and shows a form - User submits new password →
POST /reset-passwordupdates the password and clears the token
Database columns (users table)
reset_token VARCHAR(64) DEFAULT NULL -- Random token (bin2hex(random_bytes(32)))
reset_expires DATETIME DEFAULT NULL -- Expiry timestamp (1 hour from creation)
Routes
POST /forgot-password — Generate token + send email (AJAX-compatible)
GET /reset-password — Show reset form (validates token from query string)
POST /reset-password — Validate token + update password
Theme template
The reset form template is at themes/{theme}/templates/auth/reset_password.php. It receives:
$token — string (the reset token)
$valid — bool (whether the token is valid and not expired)
$error — ?string (error message)
$success — ?string (success message after password is reset)
Security
- Token is 64 hex characters (256 bits of entropy via
random_bytes(32)) - Token expires after 1 hour
- Token is cleared immediately after successful password reset
- Generic success message prevents email enumeration
- CSRF protection on all POST endpoints
- Password must be at least 8 characters, hashed with Argon2id
Best Practices
Security
- Always escape output with
htmlspecialchars()to prevent XSS attacks. - Never trust
$_GETor$_POSTdata — sanitize before displaying. - Use
loading="lazy"on images for performance.
Performance
- Keep CSS and JS files minimal. Use
functions.phponly for asset registration and helpers. - Use the priority parameter in
enqueueCSS()to control loading order. - Avoid inline
<style>and<script>when possible — use the asset system.
Compatibility
- Include
cols-NandThemeRenderer::bodyClass()on<body>to support the admin grid setting and plugins. - Always call
renderCSS(),renderJS(), and both hook actions (head.meta,footer.scripts) so plugins work correctly. - Use
$_templatefor nav highlighting — don't hardcode URL checks. - Use
$_footerColumnsto render the footer dynamically (see Footer Builder). - Prefix all custom function names with your theme name to avoid collisions.
Video Player
Always use the core Player class to render video players. It handles embed detection, FluidPlayer initialization, and responsive sizing automatically:
<!-- In your video.php template -->
<?= Player::render($video) ?>
Do not manually write <video> or <iframe> tags for the main player. See the Player section for full details.
Security note: The embed_code field contains raw HTML (typically iframes from trusted sources). It is set by the admin, not by users. Never allow user input into this field.
