SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / ResourceFolders.php⤓
<?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;
    }
}