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