<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
use seekquarry\yioop\configs as C;
/**
* ResourceFolders names the folders under the work directory's
* app/resources. Every name is made from things that are the same on
* every Yioop instance holding the same content: group names, page
* names, locales, user names, post times and host names. No id and no
* secret goes into a name, so a group's resources can be copied from one
* instance to another and are found there. Who may read a folder is
* decided by ResourceController before a folder is used, not by keeping
* the name hard to guess.
*
* The layout, with <G>, <P>, <T> and <U> the fixed names of a group, a
* wiki page, a post and a user as fixedName makes them:
*
* <G>/wiki/<locale>/<P>/page a wiki page's files
* <G>/wiki/<locale>/<P>/thumbs their thumbnails
* <G>/posts/<YYYY-MM>/<T>/page a post's files; <T> comes from the
* poster's user name and the post time
* <G>/posts/<YYYY-MM>/<T>/thumbs
* <Public G>/feeds/<YYYY-MM-DD> pictures of feed items
* users/<U> a user's icon
* site the site's appearance files
* domains/<host> one host name's appearance files
*
* A fixed name is always FIXED_NAME_LEN characters long, so it never
* equals users, site or domains.
*/
class ResourceFolders
{
/**
* Number of letters and digits of a name kept at the start of its
* fixed name, so a person looking at the folders can tell which is
* which.
*/
const READABLE_LEN = 7;
/**
* Length of the readable part of a fixed name once padded with
* PAD_CHARACTER, so the readable part always ends in at least one.
*/
const PADDED_LEN = 8;
/**
* Character that stands in for anything but A-Z, a-z and 0-9 in the
* readable part of a fixed name, and that pads it.
*/
const PAD_CHARACTER = "_";
/**
* Length of a fixed name: the padded readable part and the
* hexadecimal digits of an 8-byte crawlHash.
*/
const FIXED_NAME_LEN = 24;
/**
* Folder of a group's wiki pages, below the group's folder.
*/
const WIKI_FOLDER = "wiki";
/**
* Folder of a group's posts, below the group's folder.
*/
const POSTS_FOLDER = "posts";
/**
* Folder of the pictures of feed items, below the Public group's
* folder.
*/
const FEEDS_FOLDER = "feeds";
/**
* Folder of one page's or post's files, below its own folder.
*/
const PAGE_FOLDER = "page";
/**
* Folder of the thumbnails of one page's or post's files, below its
* own folder.
*/
const THUMBS_FOLDER = "thumbs";
/**
* Folder of users' icons, directly in app/resources.
*/
const USERS_FOLDER = "users";
/**
* Folder of the site's own appearance files, such as its logos,
* background, favicon, robots.txt and yioopbar.xml, directly in
* app/resources.
*/
const SITE_FOLDER = "site";
/**
* Folder holding one folder per host name with appearance files of
* its own, directly in app/resources.
*/
const DOMAINS_FOLDER = "domains";
/**
* SITE_IMAGES names, for each appearance role a site may upload an
* image for, the file its image is saved as in the site folder. A
* logo or background is kept as WebP and a favicon as an icon file,
* whatever was uploaded. Uploading again replaces the file, so the
* profile need not record which file plays which role.
*/
const SITE_IMAGES = ['BACKGROUND_IMAGE' => "background.webp",
'FAVICON' => "favicon.ico", 'LOGO_SMALL' => "small-logo.webp",
'LOGO_MEDIUM' => "medium-logo.webp",
'LOGO_LARGE' => "large-logo.webp"];
/**
* SITE_IMAGE_DEFAULTS gives, for each role in SITE_IMAGES, the
* address of the image Yioop ships, used while none is uploaded; a
* background has none
*/
const SITE_IMAGE_DEFAULTS = ['BACKGROUND_IMAGE' => "",
'FAVICON' => "favicon.ico",
'LOGO_SMALL' => "resources/small-logo.webp",
'LOGO_MEDIUM' => "resources/medium-logo.webp",
'LOGO_LARGE' => "resources/large-logo.webp"];
/**
* DEFAULT_DOMAIN names the folder, among the domains' folders, of the
* site's default look: what a host without a look of its own uses
*/
const DEFAULT_DOMAIN = "default";
/**
* DOMAIN_STYLESHEET names the file in a domain's folder that links to
* the theme the domain uses, from the themes in the app css folder
*/
const DOMAIN_STYLESHEET = "auxiliary.css";
/**
* NOT_THEMES names the stylesheets in the themes folder, app/css, that
* are not themes. search.css is the site's own additions to the search
* page's style, which every page reads whatever theme it uses, so it
* is never listed, edited, linked to a domain or deleted as a theme.
*/
const NOT_THEMES = ["search"];
/**
* Largest width or height, in pixels, an icon file can hold a picture
* of; a favicon made from a larger picture is scaled down to it
*/
const ICON_MAX_SIDE = 256;
/**
* Quality, from 0 to 100, a photograph uploaded as a JPEG is kept at
* when it is made a WebP. Pictures of other types are kept losslessly,
* since a logo drawn in a few colors loses its edges at any quality.
*/
const PHOTO_WEBP_QUALITY = 90;
/**
* date() format of the month folder a post's folder sits in.
*/
const POST_MONTH_FORMAT = "Y-m";
/**
* Joins a poster's user name to the post time to make the name a
* post's fixed name is made from.
*/
const POST_NAME_SEPARATOR = "@";
/**
* The files a request naming a user, rather than a group, may read
* from that user's folder.
*/
const USER_FILE_SAFE_LIST = ["user_icon.webp"];
/**
* fixedName gives the fixed-length folder name of a name. It is the
* first READABLE_LEN characters of the urlencoded name, with every
* character other than A-Z, a-z and 0-9 replaced by PAD_CHARACTER,
* padded with PAD_CHARACTER to PADDED_LEN, then the raw crawlHash of
* the name written in hexadecimal. So it holds only letters, digits
* and PAD_CHARACTER, never begins with a dot, and two names give the
* same fixed name only if their hashes agree, whatever the letter
* case of the readable part, as on the file systems of macOS and
* Windows that ignore case.
*
* @param string $name group name, page name, user name or post name
* @return string the FIXED_NAME_LEN-character folder name
*/
public static function fixedName($name)
{
$readable = preg_replace("/[^A-Za-z0-9]/", self::PAD_CHARACTER,
urlencode((string)$name));
$readable = str_pad(substr($readable, 0, self::READABLE_LEN),
self::PADDED_LEN, self::PAD_CHARACTER);
return $readable . bin2hex(crawlHash((string)$name, true));
}
/**
* basePath gives the work directory's app/resources folder, which
* every path made here is below.
*
* @param string $resource_path the folder to use in its place, for
* tests and migration; "" for app/resources
* @return string path of the folder, without a closing slash
*/
public static function basePath($resource_path = "")
{
if ($resource_path === "") {
$resource_path = C\APP_DIR . "/resources";
}
return rtrim($resource_path, "/");
}
/**
* groupPath gives the folder of all of one group's resources: its
* wiki pages, its posts and, for the Public group, the feed pictures.
*
* @param string $group_name the group's name
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the group's folder
*/
public static function groupPath($group_name, $resource_path = "")
{
$group_folder = self::fixedName($group_name);
return self::basePath($resource_path) . "/$group_folder";
}
/**
* wikiPagePath gives the folder of one wiki page, which holds its
* PAGE_FOLDER and THUMBS_FOLDER.
*
* @param string $group_name name of the page's group
* @param string $locale_tag locale the page is in, such as en-US; its
* folder keeps letters, digits and -, has PAD_CHARACTER for
* anything else, and is PAD_CHARACTER for a page with no locale
* @param string $page_name the page's name
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the page's folder
*/
public static function wikiPagePath($group_name, $locale_tag,
$page_name, $resource_path = "")
{
$locale_folder = preg_replace("/[^A-Za-z0-9\-]/",
self::PAD_CHARACTER, (string)$locale_tag);
if ($locale_folder === "") {
$locale_folder = self::PAD_CHARACTER;
}
return self::groupPath($group_name, $resource_path) . "/" .
self::WIKI_FOLDER . "/$locale_folder/" .
self::fixedName($page_name);
}
/**
* postPath gives the folder of one post, which holds its PAGE_FOLDER
* and THUMBS_FOLDER. A post is named by who wrote it and when, so a
* group's posts copied to another instance with their authors and
* times keep their folders.
*
* @param string $group_name name of the post's group
* The month folder is the post's month in Coordinated Universal Time
* (UTC), so it does not depend on an instance's time zone.
*
* @param string $user_name user name of the post's author, which is
* lowercased, as Yioop compares user names
* @param int $pubdate Unix time the post was made
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the post's folder
*/
public static function postPath($group_name, $user_name, $pubdate,
$resource_path = "")
{
$month = gmdate(self::POST_MONTH_FORMAT, (int)$pubdate);
return self::groupPath($group_name, $resource_path) . "/" .
self::POSTS_FOLDER . "/$month/" . self::fixedName(
mb_strtolower((string)$user_name) . self::POST_NAME_SEPARATOR .
(int)$pubdate);
}
/**
* feedsPath gives the folder holding, one folder per day, the
* pictures of the feed items, which belong to the Public group, named
* PUBLIC_GROUP_NAME.
*
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the feeds folder
*/
public static function feedsPath($resource_path = "")
{
$group_path = self::groupPath(C\PUBLIC_GROUP_NAME, $resource_path);
return "$group_path/" . self::FEEDS_FOLDER;
}
/**
* userPath gives the folder of one user's icon.
*
* @param string $user_name the user's name, which is lowercased, as
* Yioop compares user names
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the user's folder
*/
public static function userPath($user_name, $resource_path = "")
{
$user_folder = self::fixedName(mb_strtolower((string)$user_name));
return self::basePath($resource_path) . "/" . self::USERS_FOLDER .
"/$user_folder";
}
/**
* sitePath gives the folder of the site's own appearance files.
*
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the site folder
*/
public static function sitePath($resource_path = "")
{
$site_path = self::basePath($resource_path) . "/" .
self::SITE_FOLDER;
return $site_path;
}
/**
* siteImageFile gives the path of the image the site uses for an
* appearance role, such as LOGO_SMALL, when one has been uploaded:
* the file in the site folder SITE_IMAGES names for the role. It gives
* "" when none was uploaded, and the site then uses the image Yioop
* ships.
*
* @param string $field the role, a key of SITE_IMAGES
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the uploaded image, or "" when there is none
*/
public static function siteImageFile($field, $resource_path = "")
{
if (empty(self::SITE_IMAGES[$field])) {
return "";
}
$file = self::sitePath($resource_path) . "/" .
self::SITE_IMAGES[$field];
return file_exists($file) ? $file : "";
}
/**
* domainStylesheetFile gives the path of a domain's auxiliary.css, the
* link to the theme it uses. The empty host stands for the site's
* default look.
*
* @param string $host the domain, or "" for the site's default
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the domain's auxiliary.css, or "" for a host
* name that is empty once cleaned
*/
public static function domainStylesheetFile($host, $resource_path = "")
{
$folder = self::domainPath(($host === "") ? self::DEFAULT_DOMAIN :
$host, $resource_path);
return ($folder === "") ? "" : "$folder/" . self::DOMAIN_STYLESHEET;
}
/**
* domainStylesheetUrl gives the address of the theme a page served
* for $host uses: the host's own auxiliary.css when it has one, and
* otherwise the default domain's. Its address ends in the time the
* theme was last saved, so a browser fetches an edited theme. The
* address is relative to the site's root.
*
* @param string $host the host the page is served for
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string the stylesheet's address, or "" when neither the
* host nor the default domain uses a theme
*/
public static function domainStylesheetUrl($host, $resource_path = "")
{
foreach ([$host, self::DEFAULT_DOMAIN] as $domain) {
$file = ($domain === "") ? "" :
self::domainStylesheetFile($domain, $resource_path);
if ($file !== "" && file_exists($file)) {
$folder = basename(dirname($file));
$saved = filemtime($file);
if (C\REDIRECTS_ON) {
return "wd/resources/$folder/" . self::DOMAIN_STYLESHEET .
"?v=$saved";
}
return "?c=resource&a=get&f=resources&sf=" .
"$folder&n=" . self::DOMAIN_STYLESHEET .
"&v=$saved";
}
}
return "";
}
/**
* siteImageData turns the bytes of an uploaded picture into the bytes
* of the file the site keeps for an appearance role: a WebP for a logo
* or background, and an icon file for a favicon. A WebP or, for a
* favicon, an icon file comes back as it was. Any other picture GD
* reads, a PNG, GIF or JPEG, is drawn again, keeping its transparency;
* a JPEG photograph at PHOTO_WEBP_QUALITY and anything else losslessly.
* A favicon is written as an icon file holding a PNG, the form browsers
* read, scaled down to fit ICON_MAX_SIDE.
*
* @param string $bytes the picture as uploaded
* @param string $field the role, a key of SITE_IMAGES
* @return string|bool the file's bytes, or false when the picture
* cannot be read
*/
public static function siteImageData($bytes, $field)
{
$is_icon = substr($bytes, 0, 4) === "\0\0\x01\0";
$is_webp = substr($bytes, 0, 4) === "RIFF" &&
substr($bytes, 8, 4) === "WEBP";
$is_favicon = ($field == 'FAVICON');
if (($is_favicon && $is_icon) || (!$is_favicon && $is_webp)) {
return $bytes;
}
$image = @imagecreatefromstring($bytes);
if ($image === false) {
return false;
}
if (!imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, false);
imagesavealpha($image, true);
ob_start();
if (!$is_favicon) {
$is_jpeg = substr($bytes, 0, 3) === "\xFF\xD8\xFF";
imagewebp($image, null, $is_jpeg ? self::PHOTO_WEBP_QUALITY :
IMG_WEBP_LOSSLESS);
return ob_get_clean();
}
$width = imagesx($image);
$height = imagesy($image);
$scale = min(1, self::ICON_MAX_SIDE / max($width, $height));
if ($scale < 1) {
$image = imagescale($image, max(1, intval($width * $scale)),
max(1, intval($height * $scale)));
imagealphablending($image, false);
imagesavealpha($image, true);
$width = imagesx($image);
$height = imagesy($image);
}
imagepng($image);
$png = ob_get_clean();
/* an icon file is a six byte header saying it holds one picture,
then a sixteen byte entry giving the picture's size, where 0
stands for 256, and where in the file its bytes start */
return pack("vvv", 0, 1, 1) . pack("CCCCvvVV",
$width % self::ICON_MAX_SIDE, $height % self::ICON_MAX_SIDE,
0, 0, 1, 32, strlen($png), 22) . $png;
}
/**
* siteImageUrl gives the address a page uses for the image of an
* appearance role, such as LOGO_SMALL: the image uploaded for the
* role when siteImageFile finds one, and otherwise the one Yioop
* ships, from SITE_IMAGE_DEFAULTS. An uploaded image's address ends
* in the time it was saved, so a browser that kept an earlier upload
* under the same name fetches the new one. Like the profile values
* these replace, the address is relative to the site's root.
*
* @param string $field the role, a key of SITE_IMAGES
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string address of the image, "" for a role with no image
*/
public static function siteImageUrl($field, $resource_path = "")
{
$file = self::siteImageFile($field, $resource_path);
if ($file == "") {
return self::SITE_IMAGE_DEFAULTS[$field] ?? "";
}
$name = basename($file);
$saved = filemtime($file);
if (C\REDIRECTS_ON) {
return "wd/resources/$name?v=$saved";
}
return "?c=resource&a=get&f=resources&n=$name" .
"&v=$saved";
}
/**
* subPathInPageFolder gives the sub-folder, within its page's or
* post's page folder, of a file whose whole path is given: the part
* between <P>/page/ or <T>/page/ and the file's name, where <P> or
* <T> is a fixed name.
*
* @param string $file_path whole path of a file in a page folder
* @return string the sub-folder, such as "photos/2026", or "" for a
* file directly in the page folder or a path of another shape
*/
public static function subPathInPageFolder($file_path)
{
$pattern = "#/[A-Za-z0-9_]{" . self::PADDED_LEN . "}[0-9a-f]{" .
(self::FIXED_NAME_LEN - self::PADDED_LEN) . "}/" .
self::PAGE_FOLDER . "/(.+)/[^/]+$#";
if (!preg_match($pattern, (string)$file_path, $match)) {
return "";
}
return $match[1];
}
/**
* relativeLinkTarget gives the path by which a link at $link_path
* reaches $target_path, relative to the folder the link is in, so
* that a link made with it still works after the folder holding both
* is moved or copied. Both paths are absolute and use / between
* folders.
*
* @param string $link_path where the link is to be made
* @param string $target_path what the link is to point at
* @return string the relative path, such as ../../x/page
*/
public static function relativeLinkTarget($link_path, $target_path)
{
$from = array_values(array_filter(explode("/", dirname($link_path)),
"strlen"));
$to = array_values(array_filter(explode("/", $target_path),
"strlen"));
$shared = 0;
while ($shared < count($from) && $shared < count($to) &&
$from[$shared] === $to[$shared]) {
$shared++;
}
$ups = array_fill(0, count($from) - $shared, "..");
return implode("/", array_merge($ups, array_slice($to, $shared)));
}
/**
* domainPath gives the folder of one host name's own appearance
* files. The host name is used as it is, lowercased, after removing
* anything that could leave the folder, so the folder of a host is
* found on any instance serving that host.
*
* @param string $host the host name, such as www.example.com
* @param string $resource_path app/resources or a stand-in, as
* basePath says
* @return string path of the host's folder, or "" for a host name
* that is empty once cleaned
*/
public static function domainPath($host, $resource_path = "")
{
$host = preg_replace("/[^a-z0-9\-\.]/", "",
strtolower((string)$host));
$host = trim(str_replace("..", "", $host), ".");
if ($host === "") {
return "";
}
return self::basePath($resource_path) . "/" .
self::DOMAINS_FOLDER . "/" . $host;
}
}