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