cutelyst 5.0.0
A C++ Web Framework built on top of Qt, using the simple approach of Catalyst (Perl) framework.
staticcompressed.cpp
1/*
2 * SPDX-FileCopyrightText: (C) 2017-2023 Matthias Fehring <mf@huessenbergnetz.de>
3 * SPDX-License-Identifier: BSD-3-Clause
4 */
5
6#include "staticcompressed_p.h"
7
8#include <Cutelyst/Application>
9#include <Cutelyst/Context>
10#include <Cutelyst/Engine>
11#include <Cutelyst/Request>
12#include <Cutelyst/Response>
13#include <array>
14#include <chrono>
15
16#include <QCoreApplication>
17#include <QCryptographicHash>
18#include <QDataStream>
19#include <QDateTime>
20#include <QFile>
21#include <QLockFile>
22#include <QLoggingCategory>
23#include <QMimeDatabase>
24#include <QStandardPaths>
25
26#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
27# include <brotli/encode.h>
28#endif
29
30using namespace Cutelyst;
31using namespace Qt::Literals::StringLiterals;
32
33Q_LOGGING_CATEGORY(C_STATICCOMPRESSED, "cutelyst.plugin.staticcompressed", QtWarningMsg)
34
36 : Plugin(parent)
37 , d_ptr(new StaticCompressedPrivate)
38{
40 d->includePaths.append(parent->config(u"root"_s).toString());
41}
42
43StaticCompressed::StaticCompressed(Application *parent, const QVariantMap &defaultConfig)
44 : Plugin(parent)
45 , d_ptr(new StaticCompressedPrivate)
46{
48 d->includePaths.append(parent->config(u"root"_s).toString());
49 d->defaultConfig = defaultConfig;
50}
51
53
55{
57 d->includePaths.clear();
58 for (const QString &path : paths) {
59 d->includePaths.append(QDir(path));
60 }
61}
62
64{
66 d->dirs = dirs;
67}
68
70{
72 d->serveDirsOnly = dirsOnly;
73}
74
76{
78
79 const QVariantMap config = app->engine()->config(u"Cutelyst_StaticCompressed_Plugin"_s);
80 const QString _defaultCacheDir =
82 d->cacheDir.setPath(config
83 .value(u"cache_directory"_s,
84 d->defaultConfig.value(u"cache_directory"_s, _defaultCacheDir))
85 .toString());
86
87 if (Q_UNLIKELY(!d->cacheDir.exists())) {
88 if (!d->cacheDir.mkpath(d->cacheDir.absolutePath())) {
89 qCCritical(C_STATICCOMPRESSED)
90 << "Failed to create cache directory for compressed static files at"
91 << d->cacheDir.absolutePath();
92 return false;
93 }
94 }
95
96 qCInfo(C_STATICCOMPRESSED) << "Compressed cache directory:" << d->cacheDir.absolutePath();
97
98 const QString _mimeTypes =
99 config
100 .value(u"mime_types"_s,
101 d->defaultConfig.value(u"mime_types"_s,
102 u"text/css,application/javascript,text/javascript"_s))
103 .toString();
104 qCInfo(C_STATICCOMPRESSED) << "MIME Types:" << _mimeTypes;
105 d->mimeTypes = _mimeTypes.split(u',', Qt::SkipEmptyParts);
106
107 const QString _suffixes =
108 config
109 .value(
110 u"suffixes"_s,
111 d->defaultConfig.value(u"suffixes"_s, u"js.map,css.map,min.js.map,min.css.map"_s))
112 .toString();
113 qCInfo(C_STATICCOMPRESSED) << "Suffixes:" << _suffixes;
114 d->suffixes = _suffixes.split(u',', Qt::SkipEmptyParts);
115
116 d->checkPreCompressed = config
117 .value(u"check_pre_compressed"_s,
118 d->defaultConfig.value(u"check_pre_compressed"_s, true))
119 .toBool();
120 qCInfo(C_STATICCOMPRESSED) << "Check for pre-compressed files:" << d->checkPreCompressed;
121
122 d->onTheFlyCompression = config
123 .value(u"on_the_fly_compression"_s,
124 d->defaultConfig.value(u"on_the_fly_compression"_s, true))
125 .toBool();
126 qCInfo(C_STATICCOMPRESSED) << "Compress static files on the fly:" << d->onTheFlyCompression;
127
128 QStringList supportedCompressions{u"deflate"_s, u"gzip"_s};
129 d->loadZlibConfig(config);
130
131#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZOPFLI
132 d->loadZopfliConfig(config);
133 qCInfo(C_STATICCOMPRESSED) << "Use Zopfli:" << d->useZopfli;
134#endif
135
136#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
137 d->loadBrotliConfig(config);
138 supportedCompressions << u"br"_s;
139#endif
140
141#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZSTD
142 if (Q_UNLIKELY(!d->loadZstdConfig(config))) {
143 return false;
144 }
145 supportedCompressions << u"zstd"_s;
146#endif
147
148 const QStringList defaultCompressionFormatOrder{
149#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
150 u"br"_s,
151#endif
152#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZSTD
153 u"zstd"_s,
154#endif
155 u"gzip"_s,
156 u"deflate"_s};
157
158 QStringList _compressionFormatOrder =
159 config
160 .value(u"compression_format_order"_s,
161 d->defaultConfig.value(u"compression_format_order"_s,
162 defaultCompressionFormatOrder.join(u',')))
163 .toString()
164 .split(u',', Qt::SkipEmptyParts);
165 if (Q_UNLIKELY(_compressionFormatOrder.empty())) {
166 _compressionFormatOrder = defaultCompressionFormatOrder;
167 qCWarning(C_STATICCOMPRESSED)
168 << "Invalid or empty value for compression_format_order. Has to be a string list "
169 "containing supported values. Using default value"
170 << defaultCompressionFormatOrder.join(u',');
171 }
172 for (const auto &cfo : std::as_const(_compressionFormatOrder)) {
173 const QString order = cfo.trimmed().toLower();
174 if (supportedCompressions.contains(order)) {
175 d->compressionFormatOrder << order;
176 }
177 }
178 if (Q_UNLIKELY(d->compressionFormatOrder.empty())) {
179 d->compressionFormatOrder = defaultCompressionFormatOrder;
180 qCWarning(C_STATICCOMPRESSED)
181 << "Invalid or empty value for compression_format_order. Has to be a string list "
182 "containing supported values. Using default value"
183 << defaultCompressionFormatOrder.join(u',');
184 }
185
186 qCInfo(C_STATICCOMPRESSED) << "Supported compressions:" << supportedCompressions.join(u',');
187 qCInfo(C_STATICCOMPRESSED) << "Compression format order:"
188 << d->compressionFormatOrder.join(u',');
189 qCInfo(C_STATICCOMPRESSED) << "Include paths:" << d->includePaths;
190
191 connect(app, &Application::beforePrepareAction, this, [d](Context *c, bool *skipMethod) {
192 d->beforePrepareAction(c, skipMethod);
193 });
194
195 return true;
196}
197
198void StaticCompressedPrivate::beforePrepareAction(Context *c, bool *skipMethod)
199{
200 if (*skipMethod) {
201 return;
202 }
203
204 // TODO mid(1) quick fix for path now having leading slash
205 const QString path = c->req()->path().mid(1);
206
207 bool found = std::ranges::any_of(dirs, [&](const QString &dir) {
208 if (path.startsWith(dir)) {
209 if (!locateCompressedFile(c, path)) {
210 Response *res = c->response();
211 res->setStatus(Response::NotFound);
212 res->setContentType("text/html"_ba);
213 res->setBody(u"File not found: "_s + path);
214 }
215 return true;
216 }
217 return false;
218 });
219
220 if (found) {
221 *skipMethod = true;
222 return;
223 }
224
225 if (serveDirsOnly) {
226 return;
227 }
228
229 const QRegularExpression _re = re; // Thread-safe
230 const QRegularExpressionMatch match = _re.match(path);
231 if (match.hasMatch() && locateCompressedFile(c, path)) {
232 *skipMethod = true;
233 }
234}
235
236bool StaticCompressedPrivate::locateCompressedFile(Context *c, const QString &relPath) const
237{
238 for (const QDir &includePath : includePaths) {
239 qCDebug(C_STATICCOMPRESSED)
240 << "Trying to find" << relPath << "in" << includePath.absolutePath();
241 const QString path = includePath.absoluteFilePath(relPath);
242 const QFileInfo fileInfo(path);
243 if (fileInfo.exists()) {
244 Response *res = c->res();
245 const QDateTime currentDateTime = fileInfo.lastModified();
246 if (!c->req()->headers().ifModifiedSince(currentDateTime)) {
247 res->setStatus(Response::NotModified);
248 return true;
249 }
250
251 static QMimeDatabase db;
252 // use the extension to match to be faster
254 QByteArray contentEncoding;
255 QString compressedPath;
256 QByteArray _mimeTypeName;
257
258 if (mimeType.isValid()) {
259
260 // QMimeDatabase might not find the correct mime type for some specific types
261 // especially for map files for CSS and JS
262 if (mimeType.isDefault()) {
263 if (path.endsWith(u"css.map", Qt::CaseInsensitive) ||
264 path.endsWith(u"js.map", Qt::CaseInsensitive)) {
265 _mimeTypeName = "application/json"_ba;
266 }
267 }
268
269 if (mimeTypes.contains(mimeType.name(), Qt::CaseInsensitive) ||
270 suffixes.contains(fileInfo.completeSuffix(), Qt::CaseInsensitive)) {
271
272 const auto acceptEncoding = c->req()->header("Accept-Encoding");
273
274 for (const QString &format : std::as_const(compressionFormatOrder)) {
275 if (!acceptEncoding.contains(format.toLatin1())) {
276 continue;
277 }
278#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
279 if (format == u"br") {
280 compressedPath = locateCacheFile(path, currentDateTime, Brotli);
281 if (compressedPath.isEmpty()) {
282 continue;
283 } else {
284 qCDebug(C_STATICCOMPRESSED)
285 << "Serving brotli compressed data from" << compressedPath;
286 contentEncoding = "br"_ba;
287 break;
288 }
289 } else
290#endif
291#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZSTD
292 if (format == u"zstd") {
293 compressedPath = locateCacheFile(path, currentDateTime, Zstd);
294 if (compressedPath.isEmpty()) {
295 continue;
296 } else {
297 qCDebug(C_STATICCOMPRESSED)
298 << "Serving zstd compressed data from" << compressedPath;
299 contentEncoding = "zstd"_ba;
300 break;
301 }
302 } else
303#endif
304 if (format == u"gzip") {
305 compressedPath = locateCacheFile(
306 path, currentDateTime, useZopfli ? ZopfliGzip : Gzip);
307 if (compressedPath.isEmpty()) {
308 continue;
309 } else {
310 qCDebug(C_STATICCOMPRESSED)
311 << "Serving" << (useZopfli ? "zopfli" : "default")
312 << "compressed gzip data from" << compressedPath;
313 contentEncoding = "gzip"_ba;
314 break;
315 }
316 } else if (format == u"deflate") {
317 compressedPath = locateCacheFile(
318 path, currentDateTime, useZopfli ? ZopfliDeflate : Deflate);
319 if (compressedPath.isEmpty()) {
320 continue;
321 } else {
322 qCDebug(C_STATICCOMPRESSED)
323 << "Serving" << (useZopfli ? "zopfli" : "default")
324 << "compressed deflate data from" << compressedPath;
325 contentEncoding = "deflate"_ba;
326 break;
327 }
328 }
329 }
330 }
331 }
332
333 // Response::setBody() will take the ownership
334 // NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
335 QFile *file = !compressedPath.isEmpty() ? new QFile(compressedPath) : new QFile(path);
336 if (file->open(QFile::ReadOnly)) {
337 qCDebug(C_STATICCOMPRESSED) << "Serving" << path;
338 Headers &headers = res->headers();
339
340 // set our open file
341 res->setBody(file);
342
343 // if we have a mime type determine from the extension,
344 // do not use the name from the mime database
345 if (!_mimeTypeName.isEmpty()) {
346 headers.setContentType(_mimeTypeName);
347 } else if (mimeType.isValid()) {
348 headers.setContentType(mimeType.name().toLatin1());
349 }
350 headers.setContentLength(file->size());
351
352 headers.setLastModified(currentDateTime);
353 // Tell Firefox & friends its OK to cache, even over SSL
354 headers.setCacheControl("public"_ba);
355
356 if (!contentEncoding.isEmpty()) {
357 // serve correct encoding type
358 headers.setContentEncoding(contentEncoding);
359
360 qCDebug(C_STATICCOMPRESSED)
361 << "Encoding:" << headers.contentEncoding() << "Size:" << file->size()
362 << "Original Size:" << fileInfo.size();
363
364 // force proxies to cache compressed and non-compressed files separately
365 headers.pushHeader("Vary"_ba, "Accept-Encoding"_ba);
366 }
367
368 return true;
369 }
370
371 qCWarning(C_STATICCOMPRESSED) << "Could not serve" << path << file->errorString();
372 delete file;
373 return false;
374 }
375 }
376
377 qCWarning(C_STATICCOMPRESSED) << "File not found" << relPath;
378 return false;
379}
380
381QString StaticCompressedPrivate::locateCacheFile(const QString &origPath,
382 const QDateTime &origLastModified,
383 Compression compression) const
384{
385 QString compressedPath;
386
387 QString suffix;
388
389 switch (compression) {
390 case ZopfliGzip:
391 case Gzip:
392 suffix = u".gz"_s;
393 break;
394#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZSTD
395 case Zstd:
396 suffix = u".zst"_s;
397 break;
398#endif
399#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
400 case Brotli:
401 suffix = u".br"_s;
402 break;
403#endif
404 case ZopfliDeflate:
405 case Deflate:
406 suffix = u".deflate"_s;
407 break;
408 default:
409 Q_ASSERT_X(false, "locate cache file", "invalid compression type");
410 break;
411 }
412
413 if (checkPreCompressed) {
414 const QFileInfo origCompressed(origPath + suffix);
415 if (origCompressed.exists()) {
416 compressedPath = origCompressed.absoluteFilePath();
417 return compressedPath;
418 }
419 }
420
421 if (onTheFlyCompression) {
422
423 const QString path = cacheDir.absoluteFilePath(
426 suffix);
427 const QFileInfo info(path);
428
429 if (info.exists() && (info.lastModified() > origLastModified)) {
430 compressedPath = path;
431 } else {
432 QLockFile lock(path + u".lock");
433 if (lock.tryLock(std::chrono::milliseconds{10})) {
434 switch (compression) {
435#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZSTD
436 case Zstd:
437 if (compressZstd(origPath, path)) {
438 compressedPath = path;
439 }
440 break;
441#endif
442#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
443 case Brotli:
444 if (compressBrotli(origPath, path)) {
445 compressedPath = path;
446 }
447 break;
448#endif
449 case ZopfliGzip:
450#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZOPFLI
451 if (compressZopfli(origPath, path, ZopfliFormat::ZOPFLI_FORMAT_GZIP)) {
452 compressedPath = path;
453 }
454 break;
455#endif
456 case Gzip:
457 if (compressGzip(origPath, path, origLastModified)) {
458 compressedPath = path;
459 }
460 break;
461 case ZopfliDeflate:
462#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZOPFLI
463 if (compressZopfli(origPath, path, ZopfliFormat::ZOPFLI_FORMAT_ZLIB)) {
464 compressedPath = path;
465 }
466 break;
467#endif
468 case Deflate:
469 if (compressDeflate(origPath, path)) {
470 compressedPath = path;
471 }
472 break;
473 default:
474 break;
475 }
476 lock.unlock();
477 }
478 }
479 }
480
481 return compressedPath;
482}
483
484void StaticCompressedPrivate::loadZlibConfig(const QVariantMap &conf)
485{
486 bool ok = false;
487 zlib.compressionLevel =
488 conf.value(u"zlib_compression_level"_s,
489 defaultConfig.value(u"zlib_compression_level"_s, zlib.compressionLevelDefault))
490 .toInt(&ok);
491
492 if (!ok || zlib.compressionLevel < zlib.compressionLevelMin ||
493 zlib.compressionLevel > zlib.compressionLevelMax) {
494 qCWarning(C_STATICCOMPRESSED).nospace()
495 << "Invalid value set for zlib_compression_level. Value hat to be between "
496 << zlib.compressionLevelMin << " and " << zlib.compressionLevelMax
497 << " inclusive. Using default value " << zlib.compressionLevelDefault;
498 zlib.compressionLevel = zlib.compressionLevelDefault;
499 }
500}
501
502static constexpr std::array<quint32, 256> crc32Tab = []() {
503 std::array<quint32, 256> tab{0};
504 for (std::size_t n = 0; n < 256; n++) {
505 auto c = static_cast<quint32>(n);
506 for (int k = 0; k < 8; k++) {
507 if (c & 1) {
508 c = 0xedb88320L ^ (c >> 1);
509 } else {
510 c = c >> 1;
511 }
512 }
513 tab[n] = c;
514 }
515 return tab;
516}();
517
518quint32 updateCRC32(unsigned char ch, quint32 crc)
519{
520 // NOLINTNEXTLINE(cppcoreguidelines-avoid-magic-numbers)
521 return crc32Tab[(crc ^ ch) & 0xff] ^ (crc >> 8);
522}
523
524quint32 crc32buf(const QByteArray &data)
525{
526 return ~std::accumulate(data.begin(),
527 data.end(),
528 quint32(0xFFFFFFFF), // NOLINT(cppcoreguidelines-avoid-magic-numbers)
529 [](quint32 oldcrc32, char buf) {
530 return updateCRC32(static_cast<unsigned char>(buf), oldcrc32);
531 });
532}
533
534bool StaticCompressedPrivate::compressGzip(const QString &inputPath,
535 const QString &outputPath,
536 const QDateTime &origLastModified) const
537{
538 qCDebug(C_STATICCOMPRESSED) << "Compressing" << inputPath << "with gzip to" << outputPath;
539
540 QFile input(inputPath);
541 if (Q_UNLIKELY(!input.open(QIODevice::ReadOnly))) {
542 qCWarning(C_STATICCOMPRESSED)
543 << "Can not open input file to compress with gzip:" << inputPath;
544 return false;
545 }
546
547 const QByteArray data = input.readAll();
548 if (Q_UNLIKELY(data.isEmpty())) {
549 qCWarning(C_STATICCOMPRESSED)
550 << "Can not read input file or input file is empty:" << inputPath;
551 input.close();
552 return false;
553 }
554
555 QByteArray compressedData = qCompress(data, zlib.compressionLevel);
556 input.close();
557
558 QFile output(outputPath);
559 if (Q_UNLIKELY(!output.open(QIODevice::WriteOnly))) {
560 qCWarning(C_STATICCOMPRESSED)
561 << "Can not open output file to compress with gzip:" << outputPath;
562 return false;
563 }
564
565 if (Q_UNLIKELY(compressedData.isEmpty())) {
566 qCWarning(C_STATICCOMPRESSED)
567 << "Failed to compress file with gzip, compressed data is empty:" << inputPath;
568 if (output.exists()) {
569 if (Q_UNLIKELY(!output.remove())) {
570 qCWarning(C_STATICCOMPRESSED)
571 << "Can not remove invalid compressed gzip file:" << outputPath;
572 }
573 }
574 return false;
575 }
576
577 // Strip the first six bytes (a 4-byte length put on by qCompress and a 2-byte zlib header)
578 // and the last four bytes (a zlib integrity check).
579 compressedData.remove(0, 6);
580 compressedData.chop(4);
581
582 QByteArray header;
583 QDataStream headerStream(&header, QIODevice::WriteOnly);
584 // NOLINTBEGIN(cppcoreguidelines-avoid-magic-numbers)
585 // prepend a generic 10-byte gzip header (see RFC 1952)
586 headerStream << quint8(0x1f) << quint8(0x8b) // ID1 and ID2
587 << quint8(8) // CM / Compression Mode (8 = deflate)
588 << quint8(0) // FLG / flags
589 << static_cast<quint32>(origLastModified.toSecsSinceEpoch())
590 << quint8(0) // XFL / extra flags
591#if defined Q_OS_UNIX
592 << quint8(3);
593#elif defined Q_OS_MACOS
594 << quint8(7);
595#elif defined Q_OS_WIN
596 << quint8(11);
597#else
598 << quint8(255);
599#endif
600 // NOLINTEND(cppcoreguidelines-avoid-magic-numbers)
601
602 // append a four-byte CRC-32 of the uncompressed data
603 // append 4 bytes uncompressed input size modulo 2^32
604 auto crc = crc32buf(data);
605 auto inSize = data.size();
606 QByteArray footer;
607 QDataStream footerStream(&footer, QIODevice::WriteOnly);
608 footerStream << static_cast<quint8>(crc % 256) << static_cast<quint8>((crc >> 8) % 256)
609 << static_cast<quint8>((crc >> 16) % 256) << static_cast<quint8>((crc >> 24) % 256)
610 << static_cast<quint8>(inSize % 256) << static_cast<quint8>((inSize >> 8) % 256)
611 << static_cast<quint8>((inSize >> 16) % 256)
612 << static_cast<quint8>((inSize >> 24) % 256);
613
614 if (Q_UNLIKELY(output.write(header + compressedData + footer) < 0)) {
615 qCCritical(C_STATICCOMPRESSED).nospace()
616 << "Failed to write compressed gzip file " << inputPath << ": " << output.errorString();
617 return false;
618 }
619
620 return true;
621}
622
623bool StaticCompressedPrivate::compressDeflate(const QString &inputPath,
624 const QString &outputPath) const
625{
626 qCDebug(C_STATICCOMPRESSED) << "Compressing" << inputPath << "with deflate to" << outputPath;
627
628 QFile input(inputPath);
629 if (Q_UNLIKELY(!input.open(QIODevice::ReadOnly))) {
630 qCWarning(C_STATICCOMPRESSED)
631 << "Can not open input file to compress with deflate:" << inputPath;
632 return false;
633 }
634
635 const QByteArray data = input.readAll();
636 if (Q_UNLIKELY(data.isEmpty())) {
637 qCWarning(C_STATICCOMPRESSED)
638 << "Can not read input file or input file is empty:" << inputPath;
639 input.close();
640 return false;
641 }
642
643 QByteArray compressedData = qCompress(data, zlib.compressionLevel);
644 input.close();
645
646 QFile output(outputPath);
647 if (Q_UNLIKELY(!output.open(QIODevice::WriteOnly))) {
648 qCWarning(C_STATICCOMPRESSED)
649 << "Can not open output file to compress with deflate:" << outputPath;
650 return false;
651 }
652
653 if (Q_UNLIKELY(compressedData.isEmpty())) {
654 qCWarning(C_STATICCOMPRESSED)
655 << "Failed to compress file with deflate, compressed data is empty:" << inputPath;
656 if (output.exists()) {
657 if (Q_UNLIKELY(!output.remove())) {
658 qCWarning(C_STATICCOMPRESSED)
659 << "Can not remove invalid compressed deflate file:" << outputPath;
660 }
661 }
662 return false;
663 }
664
665 // Strip the first four bytes (a 4-byte length header put on by qCompress)
666 compressedData.remove(0, 4);
667
668 if (Q_UNLIKELY(output.write(compressedData) < 0)) {
669 qCCritical(C_STATICCOMPRESSED).nospace() << "Failed to write compressed deflate file "
670 << inputPath << ": " << output.errorString();
671 return false;
672 }
673
674 return true;
675}
676
677#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZOPFLI
678void StaticCompressedPrivate::loadZopfliConfig(const QVariantMap &conf)
679{
680 useZopfli = conf.value(u"use_zopfli"_s, defaultConfig.value(u"use_zopfli"_s, false)).toBool();
681 if (useZopfli) {
682 ZopfliInitOptions(&zopfli.options);
683 bool ok = false;
684 zopfli.options.numiterations =
685 conf.value(u"zopfli_iterations"_s,
686 defaultConfig.value(u"zopfli_iterations"_s, zopfli.iterationsDefault))
687 .toInt(&ok);
688 if (!ok || zopfli.options.numiterations < zopfli.iterationsMin) {
689 qCWarning(C_STATICCOMPRESSED).nospace()
690 << "Invalid value set for zopfli_iterations. Value has to to be an integer value "
691 "greater than or equal to "
692 << zopfli.iterationsMin << ". Using default value " << zopfli.iterationsDefault;
693 zopfli.options.numiterations = zopfli.iterationsDefault;
694 }
695 }
696}
697
698bool StaticCompressedPrivate::compressZopfli(const QString &inputPath,
699 const QString &outputPath,
700 ZopfliFormat format) const
701{
702 qCDebug(C_STATICCOMPRESSED) << "Compressing" << inputPath << "with zopfli to" << outputPath;
703
704 QFile input(inputPath);
705 if (Q_UNLIKELY(!input.open(QIODevice::ReadOnly))) {
706 qCWarning(C_STATICCOMPRESSED)
707 << "Can not open input file to compress with zopfli:" << inputPath;
708 return false;
709 }
710
711 const QByteArray data = input.readAll();
712 if (Q_UNLIKELY(data.isEmpty())) {
713 qCWarning(C_STATICCOMPRESSED)
714 << "Can not read input file or input file is empty:" << inputPath;
715 return false;
716 }
717
718 input.close();
719
720 unsigned char *out{nullptr};
721 size_t outSize{0};
722
723 ZopfliCompress(&zopfli.options,
724 format,
725 reinterpret_cast<const unsigned char *>(data.constData()),
726 data.size(),
727 &out,
728 &outSize);
729
730 if (Q_UNLIKELY(outSize <= 0)) {
731 qCWarning(C_STATICCOMPRESSED)
732 << "Failed to compress file with zopfli, compressed data is empty:" << inputPath;
733 free(out);
734 return false;
735 }
736
737 QFile output{outputPath};
738 if (Q_UNLIKELY(!output.open(QIODeviceBase::WriteOnly))) {
739 qCWarning(C_STATICCOMPRESSED) << "Failed to open output file" << outputPath
740 << "for zopfli compression:" << output.errorString();
741 free(out);
742 return false;
743 }
744
745 if (Q_UNLIKELY(output.write(reinterpret_cast<const char *>(out), outSize) < 0)) {
746 if (output.exists()) {
747 if (Q_UNLIKELY(!output.remove())) {
748 qCWarning(C_STATICCOMPRESSED)
749 << "Can not remove invalid compressed zopfli file:" << outputPath;
750 }
751 }
752 qCWarning(C_STATICCOMPRESSED) << "Failed to write zopfli compressed data to output file"
753 << outputPath << ":" << output.errorString();
754 free(out);
755 return false;
756 }
757
758 free(out);
759
760 return true;
761}
762#endif
763
764#ifdef CUTELYST_STATICCOMPRESSED_WITH_BROTLI
765void StaticCompressedPrivate::loadBrotliConfig(const QVariantMap &conf)
766{
767 bool ok = false;
768 brotli.qualityLevel =
769 conf.value(u"brotli_quality_level"_s,
770 defaultConfig.value(u"brotli_quality_level"_s, brotli.qualityLevelDefault))
771 .toInt(&ok);
772
773 if (!ok || brotli.qualityLevel < BROTLI_MIN_QUALITY ||
774 brotli.qualityLevel > BROTLI_MAX_QUALITY) {
775 qCWarning(C_STATICCOMPRESSED).nospace()
776 << "Invalid value for brotli_quality_level. "
777 "Has to be an integer value between "
778 << BROTLI_MIN_QUALITY << " and " << BROTLI_MAX_QUALITY
779 << " inclusive. Using default value " << brotli.qualityLevelDefault;
780 brotli.qualityLevel = brotli.qualityLevelDefault;
781 }
782}
783
784bool StaticCompressedPrivate::compressBrotli(const QString &inputPath,
785 const QString &outputPath) const
786{
787 qCDebug(C_STATICCOMPRESSED) << "Compressing" << inputPath << "with brotli to" << outputPath;
788
789 QFile input(inputPath);
790 if (Q_UNLIKELY(!input.open(QIODevice::ReadOnly))) {
791 qCWarning(C_STATICCOMPRESSED)
792 << "Can not open input file to compress with brotli:" << inputPath;
793 return false;
794 }
795
796 const QByteArray data = input.readAll();
797 if (Q_UNLIKELY(data.isEmpty())) {
798 qCWarning(C_STATICCOMPRESSED)
799 << "Can not read input file or input file is empty:" << inputPath;
800 return false;
801 }
802
803 input.close();
804
805 size_t outSize = BrotliEncoderMaxCompressedSize(static_cast<size_t>(data.size()));
806 if (Q_UNLIKELY(outSize == 0)) {
807 qCWarning(C_STATICCOMPRESSED) << "Needed output buffer too large to compress input of size"
808 << data.size() << "with brotli";
809 return false;
810 }
811 QByteArray outData{static_cast<qsizetype>(outSize), Qt::Uninitialized};
812
813 const auto in = reinterpret_cast<const uint8_t *>(data.constData());
814 auto out = reinterpret_cast<uint8_t *>(outData.data());
815
816 const BROTLI_BOOL status = BrotliEncoderCompress(brotli.qualityLevel,
817 BROTLI_DEFAULT_WINDOW,
818 BROTLI_DEFAULT_MODE,
819 data.size(),
820 in,
821 &outSize,
822 out);
823 if (Q_UNLIKELY(status != BROTLI_TRUE)) {
824 qCWarning(C_STATICCOMPRESSED) << "Failed to compress" << inputPath << "with brotli";
825 return false;
826 }
827
828 outData.resize(static_cast<qsizetype>(outSize));
829
830 QFile output{outputPath};
831 if (Q_UNLIKELY(!output.open(QIODeviceBase::WriteOnly))) {
832 qCWarning(C_STATICCOMPRESSED) << "Failed to open output file" << outputPath
833 << "for brotli compression:" << output.errorString();
834 return false;
835 }
836
837 if (Q_UNLIKELY(output.write(outData) < 0)) {
838 if (output.exists()) {
839 if (Q_UNLIKELY(!output.remove())) {
840 qCWarning(C_STATICCOMPRESSED)
841 << "Can not remove invalid compressed brotli file:" << outputPath;
842 }
843 }
844 qCWarning(C_STATICCOMPRESSED) << "Failed to write brotli compressed data to output file"
845 << outputPath << ":" << output.errorString();
846 return false;
847 }
848
849 return true;
850}
851#endif
852
853#ifdef CUTELYST_STATICCOMPRESSED_WITH_ZSTD
854bool StaticCompressedPrivate::loadZstdConfig(const QVariantMap &conf)
855{
856 zstd.ctx = ZSTD_createCCtx();
857 if (!zstd.ctx) {
858 qCCritical(C_STATICCOMPRESSED) << "Failed to create Zstandard compression context";
859 return false;
860 }
861
862 bool ok = false;
863
864 zstd.compressionLevel =
865 conf.value(u"zstd_compression_level"_s,
866 defaultConfig.value(u"zstd_compression_level"_s, zstd.compressionLevelDefault))
867 .toInt(&ok);
868 if (!ok || zstd.compressionLevel < ZSTD_minCLevel() ||
869 zstd.compressionLevel > ZSTD_maxCLevel()) {
870 qCWarning(C_STATICCOMPRESSED).nospace()
871 << "Invalid value for zstd_compression_level. Has to be an integer value between "
872 << ZSTD_minCLevel() << " and " << ZSTD_maxCLevel() << " inclusive. Using default value "
873 << zstd.compressionLevelDefault;
874 zstd.compressionLevel = zstd.compressionLevelDefault;
875 }
876
877 return true;
878}
879
880bool StaticCompressedPrivate::compressZstd(const QString &inputPath,
881 const QString &outputPath) const
882{
883 qCDebug(C_STATICCOMPRESSED) << "Compressing" << inputPath << "with zstd to" << outputPath;
884
885 QFile input{inputPath};
886 if (Q_UNLIKELY(!input.open(QIODeviceBase::ReadOnly))) {
887 qCWarning(C_STATICCOMPRESSED)
888 << "Can not open input file to compress with zstd:" << inputPath;
889 return false;
890 }
891
892 const QByteArray inData = input.readAll();
893 if (Q_UNLIKELY(inData.isEmpty())) {
894 qCWarning(C_STATICCOMPRESSED)
895 << "Can not read input file or input file is empty:" << inputPath;
896 return false;
897 }
898
899 input.close();
900
901 const size_t outBufSize = ZSTD_compressBound(static_cast<size_t>(inData.size()));
902 if (Q_UNLIKELY(ZSTD_isError(outBufSize) == 1)) {
903 qCWarning(C_STATICCOMPRESSED)
904 << "Failed to compress" << inputPath << "with zstd:" << ZSTD_getErrorName(outBufSize);
905 return false;
906 }
907 QByteArray outData{static_cast<qsizetype>(outBufSize), Qt::Uninitialized};
908
909 auto outDataP = static_cast<void *>(outData.data());
910 auto inDataP = static_cast<const void *>(inData.constData());
911
912 const size_t outSize = ZSTD_compressCCtx(
913 zstd.ctx, outDataP, outBufSize, inDataP, inData.size(), zstd.compressionLevel);
914 if (Q_UNLIKELY(ZSTD_isError(outSize) == 1)) {
915 qCWarning(C_STATICCOMPRESSED)
916 << "Failed to compress" << inputPath << "with zstd:" << ZSTD_getErrorName(outSize);
917 return false;
918 }
919
920 outData.resize(static_cast<qsizetype>(outSize));
921
922 QFile output{outputPath};
923 if (Q_UNLIKELY(!output.open(QIODeviceBase::WriteOnly))) {
924 qCWarning(C_STATICCOMPRESSED) << "Failed to open output file" << outputPath
925 << "for zstd compression:" << output.errorString();
926 return false;
927 }
928
929 if (Q_UNLIKELY(output.write(outData) < 0)) {
930 if (output.exists()) {
931 if (Q_UNLIKELY(!output.remove())) {
932 qCWarning(C_STATICCOMPRESSED)
933 << "Can not remove invalid compressed zstd file:" << outputPath;
934 }
935 }
936 qCWarning(C_STATICCOMPRESSED) << "Failed to write zstd compressed data to output file"
937 << outputPath << ":" << output.errorString();
938 return false;
939 }
940
941 return true;
942}
943#endif
944
945#include "moc_staticcompressed.cpp"
The Cutelyst application.
Definition application.h:66
Engine * engine() const noexcept
void beforePrepareAction(Cutelyst::Context *c, bool *skipMethod)
The Cutelyst Context.
Definition context.h:42
Response * res() const noexcept
Definition context.cpp:104
Request * req
Definition context.h:66
QVariantMap config(const QString &entity) const
Definition engine.cpp:122
Container for HTTP headers.
Definition headers.h:24
QByteArray contentEncoding() const noexcept
Definition headers.cpp:84
QByteArray ifModifiedSince() const noexcept
Definition headers.cpp:232
void setContentLength(qint64 value)
Definition headers.cpp:199
void setLastModified(const QByteArray &value)
Definition headers.cpp:298
void setCacheControl(const QByteArray &value)
Definition headers.cpp:65
void pushHeader(const QByteArray &key, const QByteArray &value)
Definition headers.cpp:489
void setContentType(const QByteArray &contentType)
Definition headers.cpp:103
void setContentEncoding(const QByteArray &encoding)
Definition headers.cpp:89
Base class for Cutelyst Plugins.
Definition plugin.h:25
QByteArray header(QAnyStringView key) const noexcept
Definition request.h:611
Headers headers() const noexcept
Definition request.cpp:312
A Cutelyst response.
Definition response.h:29
void setStatus(quint16 status) noexcept
Definition response.cpp:74
void setBody(QIODevice *body)
Definition response.cpp:105
Headers & headers() noexcept
Serve static files compressed on the fly or pre-compressed.
void setServeDirsOnly(bool dirsOnly)
void setIncludePaths(const QStringList &paths)
void setDirs(const QStringList &dirs)
StaticCompressed(Application *parent)
bool setup(Application *app) override
The Cutelyst namespace holds all public Cutelyst API.
QByteArray::iterator begin()
void chop(qsizetype n)
const char * constData() const const
QByteArray::iterator end()
bool isEmpty() const const
QByteArray & remove(qsizetype pos, qsizetype len)
qsizetype size() const const
QByteArray toHex(char separator) const const
QByteArray hash(QByteArrayView data, QCryptographicHash::Algorithm method)
qint64 toSecsSinceEpoch() const const
bool open(FILE *fh, QIODeviceBase::OpenMode mode, QFileDevice::FileHandleFlags handleFlags)
virtual qint64 size() const const override
QString errorString() const const
bool empty() const const
T value(qsizetype i) const const
QMimeType mimeTypeForFile(const QFileInfo &fileInfo, QMimeDatabase::MatchMode mode) const const
bool isValid() const const
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
QObject * parent() const const
QRegularExpressionMatch match(QStringView subjectView, qsizetype offset, QRegularExpression::MatchType matchType, QRegularExpression::MatchOptions matchOptions) const const
bool hasMatch() const const
QString writableLocation(QStandardPaths::StandardLocation type)
bool endsWith(QChar c, Qt::CaseSensitivity cs) const const
QString fromLatin1(QByteArrayView str)
bool isEmpty() const const
QString mid(qsizetype position, qsizetype n) const const
void resize(qsizetype newSize, QChar fillChar)
QStringList split(QChar sep, Qt::SplitBehavior behavior, Qt::CaseSensitivity cs) const const
bool startsWith(QChar c, Qt::CaseSensitivity cs) const const
int toInt(bool *ok, int base) const const
QString toLower() const const
QByteArray toUtf8() const const
QString trimmed() const const
QString join(QChar separator) const const
CaseInsensitive
SkipEmptyParts