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