Init
This commit is contained in:
commit
60ca58f5ba
80 changed files with 9458 additions and 0 deletions
75
bin/deploy.sh
Executable file
75
bin/deploy.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGET="${1:-local}"
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
write_build_file() {
|
||||
# $1 = repo dir, $2 = web dir
|
||||
local repo="$1" webdir="$2"
|
||||
local vmm vbump bnum version sha branch built
|
||||
vmm=$(tr -d '[:space:]' < "$repo/VERSION" 2>/dev/null || echo '0.0')
|
||||
vbump=$(git -C "$repo" log -1 --format=%H -- VERSION 2>/dev/null || echo '')
|
||||
if [ -n "$vbump" ]; then
|
||||
bnum=$(git -C "$repo" rev-list --count "${vbump}..HEAD" 2>/dev/null || echo '0')
|
||||
else
|
||||
bnum=$(git -C "$repo" rev-list --count HEAD 2>/dev/null || echo '0')
|
||||
fi
|
||||
version="${vmm}.${bnum}"
|
||||
sha=$(git -C "$repo" rev-parse --short HEAD 2>/dev/null || echo 'unknown')
|
||||
branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')
|
||||
built=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
cat > "$webdir/BUILD" <<BUILD
|
||||
version=$version
|
||||
sha=$sha
|
||||
branch=$branch
|
||||
built=$built
|
||||
BUILD
|
||||
echo " ✓ BUILD: v$version · $sha · $built"
|
||||
}
|
||||
|
||||
case "$TARGET" in
|
||||
local)
|
||||
DEST="/var/www/hackmancms"
|
||||
echo "→ local deploy to $DEST"
|
||||
sudo mkdir -p "$DEST"
|
||||
sudo rsync -av --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='data/' \
|
||||
--exclude='config/config.local.php' \
|
||||
--exclude='web/BUILD' \
|
||||
"$REPO/" "$DEST/"
|
||||
write_build_file "$REPO" "$DEST/web"
|
||||
sudo php "$DEST/bin/migrate.php"
|
||||
echo "→ done"
|
||||
;;
|
||||
|
||||
prod)
|
||||
# Code lives at /opt/hackmancms on the server; Apache root = /opt/hackmancms/web
|
||||
echo "→ prod deploy"
|
||||
ssh bashy@37.205.12.57 'set -e
|
||||
cd /opt/hackmancms
|
||||
git pull --ff-only
|
||||
VMM=$(tr -d "[:space:]" < VERSION 2>/dev/null || echo "0.0")
|
||||
VBUMP=$(git log -1 --format=%H -- VERSION 2>/dev/null || echo "")
|
||||
if [ -n "$VBUMP" ]; then
|
||||
BNUM=$(git rev-list --count "${VBUMP}..HEAD" 2>/dev/null || echo "0")
|
||||
else
|
||||
BNUM=$(git rev-list --count HEAD 2>/dev/null || echo "0")
|
||||
fi
|
||||
VERSION="${VMM}.${BNUM}"
|
||||
SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
BUILT=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
printf "version=%s\nsha=%s\nbranch=%s\nbuilt=%s\n" "$VERSION" "$SHA" "$BRANCH" "$BUILT" > web/BUILD
|
||||
echo " BUILD: v$VERSION · $SHA · $BUILT"
|
||||
php bin/migrate.php
|
||||
'
|
||||
echo "→ done"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 [local|prod]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
289
bin/import-site-logs.php
Normal file
289
bin/import-site-logs.php
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<?php
|
||||
/**
|
||||
* Tail web server access logs into site_visits.
|
||||
*
|
||||
* Run as the user that can read the configured log files (typically root or
|
||||
* a member of adm). Cron entry — every 5 minutes, all projects:
|
||||
*
|
||||
* *\/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php
|
||||
*
|
||||
* Or one project at a time (used by the "Import now" button):
|
||||
*
|
||||
* php /opt/hackmancms/bin/import-site-logs.php --project=42
|
||||
*
|
||||
* Tracks per-project state in project_settings:
|
||||
* analytics_log_path configured log file
|
||||
* analytics_log_format 'combined' | 'nginx'
|
||||
* analytics_log_filter optional path prefix filter
|
||||
* analytics_last_size last byte position
|
||||
* analytics_last_inode last file inode (for rotation detection)
|
||||
* analytics_imported_at last import timestamp
|
||||
* analytics_imported_count cumulative rows imported
|
||||
*/
|
||||
|
||||
if (php_sapi_name() !== 'cli' && empty($_GET['project_id'])) {
|
||||
// Allow inclusion via API endpoint too.
|
||||
}
|
||||
|
||||
if (!defined('ROOT')) {
|
||||
define('ROOT', dirname(__DIR__));
|
||||
require ROOT . '/lib/bootstrap.php';
|
||||
}
|
||||
|
||||
$onlyProject = null;
|
||||
foreach ($argv ?? [] as $a) {
|
||||
if (preg_match('/^--project=(\d+)$/', $a, $m)) $onlyProject = (int)$m[1];
|
||||
}
|
||||
|
||||
$result = importAllProjects($db, $onlyProject);
|
||||
if (php_sapi_name() === 'cli') {
|
||||
foreach ($result as $r) {
|
||||
printf("[%s] project=%d imported=%d skipped=%d %s\n",
|
||||
date('Y-m-d H:i:s'), $r['project_id'],
|
||||
$r['imported'], $r['skipped'],
|
||||
$r['error'] ? 'ERROR: ' . $r['error'] : '');
|
||||
}
|
||||
}
|
||||
|
||||
function importAllProjects(PDO $db, ?int $onlyProject = null): array {
|
||||
$args = [];
|
||||
$sql = 'SELECT id, name FROM projects WHERE is_active = 1';
|
||||
if ($onlyProject !== null) { $sql .= ' AND id = ?'; $args[] = $onlyProject; }
|
||||
$st = $db->prepare($sql); $st->execute($args);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $p) {
|
||||
$out[] = importOne($db, (int)$p['id']);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function importOne(PDO $db, int $pid): array {
|
||||
$cfg = readProjectSettings($db, $pid);
|
||||
$path = trim((string)($cfg['analytics_log_path'] ?? ''));
|
||||
$fmt = (string)($cfg['analytics_log_format'] ?? 'combined');
|
||||
$pre = trim((string)($cfg['analytics_log_filter'] ?? ''));
|
||||
if ($path === '') return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0, 'error' => null];
|
||||
if (!is_readable($path)) {
|
||||
return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0,
|
||||
'error' => "log not readable: $path"];
|
||||
}
|
||||
|
||||
$stat = stat($path);
|
||||
$size = $stat['size'] ?? 0;
|
||||
$inode = $stat['ino'] ?? 0;
|
||||
$lastSize = (int)($cfg['analytics_last_size'] ?? 0);
|
||||
$lastInode = (int)($cfg['analytics_last_inode'] ?? 0);
|
||||
|
||||
// Detect rotation: file replaced or shrank.
|
||||
if ($inode !== $lastInode || $size < $lastSize) {
|
||||
$lastSize = 0;
|
||||
}
|
||||
if ($size === $lastSize) {
|
||||
return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0, 'error' => null];
|
||||
}
|
||||
|
||||
$fh = @fopen($path, 'rb');
|
||||
if (!$fh) return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0,
|
||||
'error' => 'fopen failed'];
|
||||
fseek($fh, $lastSize);
|
||||
|
||||
$imported = 0; $skipped = 0;
|
||||
$ins = $db->prepare(
|
||||
'INSERT INTO site_visits (project_id, path, referrer, ua_hash, ip_hash, status, visited_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
$baseSalt = 'hackmancms-site-visits';
|
||||
|
||||
$db->beginTransaction();
|
||||
while (!feof($fh)) {
|
||||
$line = fgets($fh);
|
||||
if ($line === false) break;
|
||||
$row = parseLogLine($line, $fmt);
|
||||
if (!$row) { $skipped++; continue; }
|
||||
if (!isInteresting($row, $pre)) { $skipped++; continue; }
|
||||
// Rotating salt: hash with the visit's date, so the same IP gets a
|
||||
// different hash on different days. Same-day uniques are exact;
|
||||
// cross-day visitors look like new visitors → counts cease to be PII.
|
||||
$daySalt = $baseSalt . substr($row['ts'], 0, 10);
|
||||
$uaHash = $row['ua'] ? substr(hash('sha256', $daySalt . $row['ua']), 0, 16) : null;
|
||||
$ipHash = $row['ip'] ? substr(hash('sha256', $daySalt . $row['ip']), 0, 16) : null;
|
||||
$ins->execute([$pid, $row['path'], $row['ref'] ?: null, $uaHash, $ipHash,
|
||||
$row['status'], $row['ts']]);
|
||||
$imported++;
|
||||
}
|
||||
$newSize = ftell($fh);
|
||||
fclose($fh);
|
||||
$db->commit();
|
||||
|
||||
$totalCount = (int)($cfg['analytics_imported_count'] ?? 0) + $imported;
|
||||
saveSetting($db, $pid, 'analytics_last_size', (string)$newSize);
|
||||
saveSetting($db, $pid, 'analytics_last_inode', (string)$inode);
|
||||
saveSetting($db, $pid, 'analytics_imported_at', date('Y-m-d H:i:s'));
|
||||
saveSetting($db, $pid, 'analytics_imported_count', (string)$totalCount);
|
||||
|
||||
Audit::log($db, 'analytics_import', $pid, "rows=$imported skipped=$skipped");
|
||||
|
||||
// Roll up + age out (throttled to once per 24h per project).
|
||||
$rollupStats = maybeRollupAndPrune($db, $pid, $cfg);
|
||||
return ['project_id' => $pid, 'imported' => $imported, 'skipped' => $skipped,
|
||||
'rollup' => $rollupStats, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build hourly + daily rollups for any finalized days (date < today), then
|
||||
* age out raw events older than 90d and hourly buckets older than 365d.
|
||||
*
|
||||
* raw events 0–90 days — full hour-level + visit-level detail
|
||||
* site_visits_hourly 90–365 — hour buckets, pulled from raw before drop
|
||||
* site_visits_daily 365+ — day buckets, retained forever
|
||||
*
|
||||
* Throttled to once per 24h per project. Rollups are idempotent via
|
||||
* UNIQUE constraint on (project, bucket, path, status, referrer) +
|
||||
* INSERT OR REPLACE — re-running rolls produces the same rows.
|
||||
*
|
||||
* Aging-out is safe: a row is only dropped from raw after the daily/hourly
|
||||
* row it contributes to has been written.
|
||||
*/
|
||||
function maybeRollupAndPrune(PDO $db, int $pid, array $cfg): array {
|
||||
$stats = [
|
||||
'skipped' => false,
|
||||
'hourly_buckets' => 0,
|
||||
'daily_buckets' => 0,
|
||||
'raw_dropped' => 0,
|
||||
'hourly_dropped' => 0,
|
||||
];
|
||||
$last = $cfg['analytics_last_rollup'] ?? null;
|
||||
if ($last && strtotime($last) > time() - 86400) {
|
||||
$stats['skipped'] = true;
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// Find days in raw that aren't today; those are eligible to be rolled up.
|
||||
$first = $db->prepare(
|
||||
"SELECT MIN(date(visited_at)) FROM site_visits
|
||||
WHERE project_id = ? AND date(visited_at) < ?");
|
||||
$first->execute([$pid, $today]);
|
||||
$firstDay = $first->fetchColumn();
|
||||
|
||||
if ($firstDay) {
|
||||
$hourlyIns = $db->prepare(
|
||||
"INSERT OR REPLACE INTO site_visits_hourly
|
||||
(project_id, bucket_at, path, status, referrer, views, uniques)
|
||||
SELECT ?, strftime('%Y-%m-%d %H:00:00', visited_at),
|
||||
path, status, COALESCE(referrer, ''),
|
||||
COUNT(*), COUNT(DISTINCT ip_hash)
|
||||
FROM site_visits
|
||||
WHERE project_id = ? AND date(visited_at) = ?
|
||||
GROUP BY strftime('%Y-%m-%d %H', visited_at), path, status, COALESCE(referrer, '')");
|
||||
$dailyIns = $db->prepare(
|
||||
"INSERT OR REPLACE INTO site_visits_daily
|
||||
(project_id, bucket_at, path, status, referrer, views, uniques)
|
||||
SELECT ?, ?, path, status, COALESCE(referrer, ''),
|
||||
COUNT(*), COUNT(DISTINCT ip_hash)
|
||||
FROM site_visits
|
||||
WHERE project_id = ? AND date(visited_at) = ?
|
||||
GROUP BY path, status, COALESCE(referrer, '')");
|
||||
|
||||
$d = $firstDay;
|
||||
$endDay = date('Y-m-d', strtotime($today . ' -1 day')); // yesterday
|
||||
while ($d <= $endDay) {
|
||||
$hourlyIns->execute([$pid, $pid, $d]);
|
||||
$stats['hourly_buckets'] += $hourlyIns->rowCount();
|
||||
$dailyIns->execute([$pid, $d, $pid, $d]);
|
||||
$stats['daily_buckets'] += $dailyIns->rowCount();
|
||||
$d = date('Y-m-d', strtotime($d . ' +1 day'));
|
||||
}
|
||||
}
|
||||
|
||||
// Raw older than 90 days — already preserved in daily + hourly rollups.
|
||||
$st = $db->prepare(
|
||||
"DELETE FROM site_visits
|
||||
WHERE project_id = ? AND visited_at < datetime('now', '-90 days')");
|
||||
$st->execute([$pid]);
|
||||
$stats['raw_dropped'] = $st->rowCount();
|
||||
|
||||
// Hourly older than 365 days — already preserved in daily rollups.
|
||||
$st = $db->prepare(
|
||||
"DELETE FROM site_visits_hourly
|
||||
WHERE project_id = ? AND bucket_at < datetime('now', '-365 days')");
|
||||
$st->execute([$pid]);
|
||||
$stats['hourly_dropped'] = $st->rowCount();
|
||||
|
||||
saveSetting($db, $pid, 'analytics_last_rollup', date('Y-m-d H:i:s'));
|
||||
if ($stats['hourly_buckets'] || $stats['daily_buckets']
|
||||
|| $stats['raw_dropped'] || $stats['hourly_dropped']) {
|
||||
Audit::log($db, 'analytics_rollup', $pid, json_encode($stats));
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
function readProjectSettings(PDO $db, int $pid): array {
|
||||
$st = $db->prepare('SELECT key, value FROM project_settings WHERE project_id = ?');
|
||||
$st->execute([$pid]);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) $out[$r['key']] = $r['value'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
function saveSetting(PDO $db, int $pid, string $key, ?string $value): void {
|
||||
$db->prepare('INSERT OR REPLACE INTO project_settings (project_id, key, value)
|
||||
VALUES (?, ?, ?)')->execute([$pid, $key, $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single log line. Combined log format and nginx default share the same
|
||||
* field layout — IP - - [ts] "REQ" status size "ref" "ua" — so one regex covers
|
||||
* both for our purposes.
|
||||
*/
|
||||
function parseLogLine(string $line, string $fmt): ?array {
|
||||
$line = rtrim($line);
|
||||
if ($line === '') return null;
|
||||
// Combined / nginx default.
|
||||
if (!preg_match(
|
||||
'/^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) [^"]*" (\d+) \S+ "([^"]*)" "([^"]*)"/',
|
||||
$line, $m
|
||||
)) return null;
|
||||
[, $ip, $tsRaw, $method, $url, $status, $ref, $ua] = $m;
|
||||
$ts = parseLogDate($tsRaw);
|
||||
if (!$ts) return null;
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: $url;
|
||||
return [
|
||||
'ip' => $ip,
|
||||
'ts' => $ts,
|
||||
'method' => strtoupper($method),
|
||||
'path' => $path,
|
||||
'status' => (int)$status,
|
||||
'ref' => $ref === '-' ? '' : $ref,
|
||||
'ua' => $ua === '-' ? '' : $ua,
|
||||
];
|
||||
}
|
||||
|
||||
function parseLogDate(string $raw): ?string {
|
||||
// 03/May/2026:08:34:56 +0200
|
||||
$dt = DateTime::createFromFormat('d/M/Y:H:i:s O', $raw);
|
||||
return $dt ? $dt->format('Y-m-d H:i:s') : null;
|
||||
}
|
||||
|
||||
function isInteresting(array $row, string $pathPrefix): bool {
|
||||
if ($row['method'] !== 'GET') return false;
|
||||
// Keep 2xx, 3xx, AND 404 (we want to surface broken-link hits separately).
|
||||
// Drop 5xx, 401/403/etc.
|
||||
if ($row['status'] < 200) return false;
|
||||
if ($row['status'] >= 400 && $row['status'] !== 404) return false;
|
||||
if ($pathPrefix !== '' && !str_starts_with($row['path'], $pathPrefix)) return false;
|
||||
|
||||
// Skip asset extensions
|
||||
$ext = strtolower((string)pathinfo($row['path'], PATHINFO_EXTENSION));
|
||||
static $asset = ['css','js','png','jpg','jpeg','gif','webp','svg','ico',
|
||||
'woff','woff2','ttf','eot','otf','map','mp4','webm',
|
||||
'mp3','wav','ogg','pdf','zip','tar','gz','xml','txt'];
|
||||
if (in_array($ext, $asset, true)) return false;
|
||||
|
||||
// Skip obvious bots
|
||||
$ua = strtolower((string)$row['ua']);
|
||||
foreach (['bot','crawl','spider','curl','wget','headless','scrapy','go-http','python-requests','okhttp'] as $needle) {
|
||||
if (str_contains($ua, $needle)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
10
bin/migrate.php
Executable file
10
bin/migrate.php
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
define('ROOT', dirname(__DIR__));
|
||||
$config = require ROOT . '/config/config.php';
|
||||
require_once ROOT . '/lib/DB.php';
|
||||
|
||||
DB::connect($config['db_path']);
|
||||
$applied = DB::autoMigrate(ROOT . '/sql');
|
||||
foreach ($applied as $v) echo " applied: $v\n";
|
||||
if (!$applied) echo " Nothing to do.\n";
|
||||
86
bin/run-schedules.php
Normal file
86
bin/run-schedules.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
/**
|
||||
* Cron-driven scheduled build runner.
|
||||
*
|
||||
* Install once on the host (as the apache user so it can write data/):
|
||||
* * * * * * /usr/bin/php /opt/hackmancms/bin/run-schedules.php
|
||||
*
|
||||
* Each run: enumerate enabled schedules, run any whose cron expression matches
|
||||
* the current minute (and that haven't already run within the last minute),
|
||||
* record output to command_history, update last_run_at/last_status.
|
||||
*/
|
||||
|
||||
define('ROOT', dirname(__DIR__));
|
||||
require ROOT . '/lib/bootstrap.php';
|
||||
ProjectTypes::load();
|
||||
|
||||
$now = time();
|
||||
$now = $now - ($now % 60); // truncate to minute boundary
|
||||
$nowSql = date('Y-m-d H:i:s', $now);
|
||||
|
||||
$schedules = $db->query(
|
||||
'SELECT s.*, p.path AS project_path, p.type AS project_type
|
||||
FROM scheduled_builds s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.is_enabled = 1 AND p.is_active = 1'
|
||||
)->fetchAll();
|
||||
|
||||
foreach ($schedules as $s) {
|
||||
if (!cronMatches($s['cron'], $now)) continue;
|
||||
if ($s['last_run_at'] && (strtotime($s['last_run_at']) >= $now)) continue;
|
||||
|
||||
$type = ProjectTypes::get($s['project_type']);
|
||||
if (!$type) continue;
|
||||
$cmd = null;
|
||||
foreach ($type::commands() as $c) if ($c['id'] === $s['cmd_id']) { $cmd = $c; break; }
|
||||
if (!$cmd) continue;
|
||||
|
||||
$base = realpath($s['project_path']);
|
||||
if (!$base || !is_dir($base)) continue;
|
||||
|
||||
fwrite(STDOUT, "[$nowSql] running schedule #{$s['id']} cmd={$cmd['id']} project={$s['project_id']}\n");
|
||||
exec('cd ' . escapeshellarg($base) . ' && ' . $cmd['cmd'] . ' 2>&1', $out, $rc);
|
||||
$outText = implode("\n", $out);
|
||||
$status = $rc === 0 ? 'ok' : ('exit=' . $rc);
|
||||
|
||||
$db->prepare('INSERT INTO command_history (project_id, cmd_id, cmd, output, exit_code)
|
||||
VALUES (?, ?, ?, ?, ?)')
|
||||
->execute([$s['project_id'], $s['cmd_id'], $cmd['cmd'], $outText, $rc]);
|
||||
$db->prepare('UPDATE scheduled_builds SET last_run_at = ?, last_status = ? WHERE id = ?')
|
||||
->execute([$nowSql, $status, $s['id']]);
|
||||
Audit::log($db, 'scheduled_build', $s['project_id'], "{$cmd['id']} status=$status");
|
||||
unset($out);
|
||||
}
|
||||
|
||||
/** Match a 5-field cron expression against a unix timestamp. */
|
||||
function cronMatches(string $expr, int $ts): bool {
|
||||
$fields = preg_split('/\s+/', trim($expr));
|
||||
if (count($fields) !== 5) return false;
|
||||
[$min, $hour, $dom, $mon, $dow] = $fields;
|
||||
$t = getdate($ts);
|
||||
return cronField($min, $t['minutes'], 0, 59)
|
||||
&& cronField($hour, $t['hours'], 0, 23)
|
||||
&& cronField($dom, $t['mday'], 1, 31)
|
||||
&& cronField($mon, $t['mon'], 1, 12)
|
||||
&& cronField($dow, $t['wday'], 0, 6); // 0=Sun
|
||||
}
|
||||
|
||||
function cronField(string $field, int $value, int $min, int $max): bool {
|
||||
foreach (explode(',', $field) as $part) {
|
||||
$step = 1;
|
||||
if (str_contains($part, '/')) { [$part, $step] = explode('/', $part, 2); $step = max(1, (int)$step); }
|
||||
if ($part === '*' || $part === '') {
|
||||
if (($value - $min) % $step === 0) return true;
|
||||
continue;
|
||||
}
|
||||
if (str_contains($part, '-')) {
|
||||
[$a, $b] = array_map('intval', explode('-', $part, 2));
|
||||
} else {
|
||||
$a = $b = (int)$part;
|
||||
}
|
||||
for ($v = $a; $v <= $b; $v++) {
|
||||
if ((($v - $a) % $step === 0) && $v === $value) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue