FIX #33065 page hangs when displaying a very long html content (#39260)

* FIX #39190 wrong unit price when invoicing time spent with one line per declaration (#39215)

* FIX #39190 wrong unit price when invoicing time spent with one line per declaration

In projet/tasks/time.php, the 'onelineperperiod' mode (invoice generation option
"One line for each time spent declaration") computed the unit price as

    $pu_ht = price2num($value['totalvaluetodivideby3600'] / 3600, 'MU');

but totalvaluetodivideby3600 is timespent_duration (seconds) * timespent_thm
(hourly rate), so dividing by 3600 yields "hours * hourly rate", which is the line
total and not a unit price. The quantity passed to addline() is the number of hours,
so the line was billed hours * (hours * rate) instead of hours * rate: the time is
over-billed by a factor equal to the number of hours.

The sibling modes are already correct: 'onelineperuser' and 'onelinepertask' divide
by the recorded number of seconds. Up to 16.0 'onelineperuser' had the same /3600
expression; it was fixed there and 'onelineperperiod' was left behind. Align it with
that reference implementation, including the same guard against a zero duration.

Also reset $pu_ht for each generated line. $pu_ht is initialized once before the loops
(with the price of the selected product/service, or 0) and this mode only had an
"if (empty($pu_ht))" test, so once a price was computed for the first line every
following line reused it, and lines of users with a different hourly rate got the rate
of the first line. The base price is saved before the loop and restored on each
iteration, so the product/service price path is preserved.

* Rename variable for clarity in time.php

---------

Co-authored-by: Laurent Destailleur <eldy@destailleur.fr>

* FIX isInt() does not detect sized integer types (tinyint, bigint) (#39236)

CommonObject::isInt() decides whether a list-page column gets numeric
search mode and is also used when resolving $this->id. The regex
/(^int|int$)/ missed sized integer types such as tinyint(4) and
bigint(20): they neither start nor end with "int".

These types are not exotic: tinyint(4) is used in several core $fields
definitions (societe, contact, entrepot, companypaymentmode, ...) and
ModuleBuilder-generated tables use bigint(20) for rowid, so isInt()
should recognise them rather than requiring callers to change the
column type.

The new regex /^(?:tiny|small|medium|big)?int|int$/ also handles
smallint/mediumint with a size. Anchoring is preserved on purpose so
that strings which merely contain "int" (e.g. sellist:Table:...) are
still NOT matched, while the Dolibarr foreign-key syntax
"integer:Class:path" keeps being detected.

Signed-off-by: Jam Balaya <jambalaya.pyoncafe@outlook.jp>

* FIX #34667 checkUserAccessToObject: skip entity check for non-multientity objects (multicompany) (#39246)

When the multicompany module is enabled, checkUserAccessToObject() (used
by restrictedArea(), e.g. from an ajax tooltip) ran the entity check
unconditionally in its default/custom-object branch, building
"AND dbt.entity IN (...)". For a custom object whose table has no entity
column (ismultientitymanaged = 0), that SQL fails, nb comes back 0 and
access is wrongly denied.

Guard the branch so the entity test only runs when the object is actually
multi-entity managed. Keep the test for legacy classes that never define
ismultientitymanaged (null/unset) and for a non-object $object, and skip
it only when a class explicitly sets ismultientitymanaged = 0.

Supersedes the stale #34667 (by @jyhere) and applies the condition
requested by @eldy in its review.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* FIX mo_production: show very-short unit label instead of untranslated 'UnitPShort' (#39253)

The "Unit" column of the consume/produce tables used
measuringUnitString(..., 2), which translates a <Label>Short key. Those
keys do not exist for most units (e.g. a Piece -> 'UnitPShort', kg ->
'WeightUnitkgShort'), so the raw untranslated key was displayed. Use
param 1 (the very-short DB short_label: 'p', 'kg', ...) like every other
unit column in this file and across the codebase.

Supersedes #31706.

Signed-off-by: Jam Balaya <jambalaya.pyoncafe@outlook.jp>

* FIX #32217 Fire BILL_CREATE with create-form extrafields on next situation invoice (#39254)

When creating the next situation invoice, the create-form extrafields were
saved AFTER createFromCurrent() had already fired BILL_CREATE, so triggers
listening to BILL_CREATE never saw them. Passing 'BILL_CREATE' to
insertExtraFields() (as first proposed) would fire the trigger a second time.

Instead, set the extrafields on $object before createFromCurrent() and let
create() insert them and fire BILL_CREATE once with the values present.
createFromCurrent() now skips its fetch_optionals() when array_options is
already set by the caller, so the form values are no longer overwritten by
the source invoice values. The two other createFromCurrent() callers do not
pre-set array_options, so their behaviour is unchanged.

Supersedes #32217.

Signed-off-by: Jam Balaya <jambalaya.pyoncafe@outlook.jp>

* FIX #33065 page hangs when displaying a very long html content

dol_escape_htmltag() protects the tags listed in $noescapetags before calling
htmlentities(). For the tags carrying attributes it used a do/while loop that,
on each round, searched the first '<tag ...>' with preg_match, replaced every
occurrence of that exact string with str_replace, then compared the whole
content with strcmp to decide whether to loop again.

That loop only consumes one distinct attribute string per round, and each round
scans, rebuilds and compares the whole content. With the 'common' tag list (48
tags) the cost is O(48 * number of distinct attributed tags * length), so it
degrades quadratically with the size of the content while the memory usage stays
flat. A long note or ticket message therefore burns CPU until
max_execution_time is reached instead of raising a memory error.

Replace that loop with a single preg_replace_callback() pass per tag. The
replacement string contains no '<', so it can never build a new tag to protect
and one pass is enough. The attribute string is transformed by exactly the same
operations, in the same order, so the output is unchanged.

Measured on the same content (48 tags, distinct attributes per tag), before and
after, with identical output:

  spans   length    before      after    speedup
    500    29 KB    0.044s     0.006s       7.9x
   1000    59 KB    0.140s     0.010s      13.4x
   2000   121 KB    0.822s     0.028s      29.1x
   4000   245 KB    2.361s     0.042s      56.2x
   8000   493 KB   11.813s     0.171s      69.0x

Equivalence was checked on 26 hand written cases (attributes with brackets,
quotes, forbidden characters, unclosed tags, '<' inside an attribute, reserved
keywords, upper case tags, tabs and newlines) and on 3000 randomly generated
inputs: the old and the new code return the same string for all of them.

---------

Signed-off-by: Jam Balaya <jambalaya.pyoncafe@outlook.jp>
Co-authored-by: Laurent Destailleur <eldy@destailleur.fr>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jam Balaya 2026-07-24 00:38:42 +09:00 committed by GitHub
parent 5529fb9592
commit b8dc0d34ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2601,21 +2601,26 @@ function dol_escape_htmltag($stringtoescape, $keepb = 0, $keepn = 0, $noescapeta
$tmp = str_ireplace('</' . $tagtoreplace . '>', '__ENDTAGTOREPLACE' . $tagtoreplace . '__', $tmp);
$tmp = preg_replace('/<' . preg_quote($tagtoreplace, '/') . ' \/>/', '__BEGINENDTAGTOREPLACE' . $tagtoreplace . '__', $tmp);
// For case of tag with attributes
do {
$tmpold = $tmp;
if (preg_match('/<' . preg_quote($tagtoreplace, '/') . '(\s+)([^>]+)>/', $tmp, $reg)) {
// For case of tag with attributes.
// All the occurrences are protected in a single pass: the replacement string contains no '<', so it
// can never build a new tag to protect (a loop replacing one distinct attribute string per round was
// rescanning the whole content for each of them, so the cost was quadratic on large contents).
$tmp = preg_replace_callback(
'/<'.preg_quote($tagtoreplace, '/').'(\s+)([^>]+)>/',
/**
* @param string[] $reg
* @return string
*/
static function ($reg) use ($tagtoreplace) {
// We want to protect the attribute part ... in '<xxx ...>' to avoid transformation by htmlentities() later
$tmpattributes = str_ireplace(array('[', ']'), '_', $reg[2]); // We must never have [ ] inside the attribute string
$tmpattributes = str_ireplace('"', '__DOUBLEQUOTE__', $tmpattributes);
$tmpattributes = preg_replace('/[^a-z0-9_%,\/\?\;\s=&\.\-@:\.#\+]/i', '', $tmpattributes);
//$tmpattributes = preg_replace("/float:\s*(left|right)/", "", $tmpattributes); // Disabled: we must not remove content
$tmp = str_replace('<' . $tagtoreplace . $reg[1] . $reg[2] . '>', '__BEGINTAGTOREPLACE' . $tagtoreplace . '[' . $tmpattributes . ']__', $tmp);
}
$diff = strcmp($tmpold, $tmp);
} while ($diff);
return '__BEGINTAGTOREPLACE'.$tagtoreplace.'['.$tmpattributes.']__';
},
$tmp
) ?? $tmp;
}
$tmp = str_ireplace('&amp', '__ANDNOSEMICOLON__', $tmp);