cutelyst  4.6.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 
30 using namespace Cutelyst;
31 
32 Q_LOGGING_CATEGORY(C_STATICCOMPRESSED, "cutelyst.plugin.staticcompressed", QtWarningMsg)
33 
34 StaticCompressed::StaticCompressed(Application *parent)
35  : Plugin(parent)
36  , d_ptr(new StaticCompressedPrivate)
37 {
38  Q_D(StaticCompressed);
39  d->includePaths.append(parent->config(u"root"_qs).toString());
40 }
41 
42 StaticCompressed::StaticCompressed(Application *parent, const QVariantMap &defaultConfig)
43  : Plugin(parent)
44  , d_ptr(new StaticCompressedPrivate)
45 {
46  Q_D(StaticCompressed);
47  d->includePaths.append(parent->config(u"root"_qs).toString());
48  d->defaultConfig = defaultConfig;
49 }
50 
51 StaticCompressed::~StaticCompressed() = default;
52 
53 void StaticCompressed::setIncludePaths(const QStringList &paths)
54 {
55  Q_D(StaticCompressed);
56  d->includePaths.clear();
57  for (const QString &path : paths) {
58  d->includePaths.append(QDir(path));
59  }
60 }
61 
62 void StaticCompressed::setDirs(const QStringList &dirs)
63 {
64  Q_D(StaticCompressed);
65  d->dirs = dirs;
66 }
67 
68 void StaticCompressed::setServeDirsOnly(bool dirsOnly)
69 {
70  Q_D(StaticCompressed);
71  d->serveDirsOnly = dirsOnly;
72 }
73 
74 bool StaticCompressed::setup(Application *app)
75 {
76  Q_D(StaticCompressed);
77 
78  const QVariantMap config = app->engine()->config(u"Cutelyst_StaticCompressed_Plugin"_qs);
79  const QString _defaultCacheDir =
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 
198 void 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 
232 bool 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 
377 QString 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(
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 
480 void 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 
498 static 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 
514 quint32 updateCRC32(unsigned char ch, quint32 crc)
515 {
516  // NOLINTNEXTLINE(cppcoreguidelines-avoid-magic-numbers)
517  return crc32Tab[(crc ^ ch) & 0xff] ^ (crc >> 8);
518 }
519 
520 quint32 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 
530 bool 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 
619 bool 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
674 void 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 
694 bool 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
761 void 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 
780 bool 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
850 bool 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 
876 bool 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"
QByteArray header(QByteArrayView key) const noexcept
Definition: request.h:611
QString writableLocation(QStandardPaths::StandardLocation type)
Serve static files compressed on the fly or pre-compressed.
Headers & headers() noexcept
QString errorString() const const
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
Response * res() const noexcept
Definition: context.cpp:103
void chop(qsizetype n)
void setContentEncoding(const QByteArray &encoding)
Definition: headers.cpp:62
bool isEmpty() const const
Container for HTTP headers.
Definition: headers.h:23
void setLastModified(const QByteArray &value)
Definition: headers.cpp:271
STL namespace.
QString join(QChar separator) const const
Request req
Definition: context.h:67
void setContentType(const QByteArray &type)
Definition: response.h:238
T value(qsizetype i) const const
A Cutelyst response.
Definition: response.h:28
void setCacheControl(const QByteArray &value)
Definition: headers.cpp:38
The Cutelyst Context.
Definition: context.h:42
bool empty() const const
void setContentType(const QByteArray &contentType)
Definition: headers.cpp:76
Headers headers() const noexcept
Definition: request.cpp:312
bool startsWith(QChar c, Qt::CaseSensitivity cs) const const
CaseInsensitive
void beforePrepareAction(Cutelyst::Context *c, bool *skipMethod)
QVariantMap config(const QString &entity) const
Definition: engine.cpp:263
void resize(qsizetype newSize, QChar fillChar)
bool isEmpty() const const
QString trimmed() const const
const char * constData() const const
QByteArray::iterator begin()
bool hasMatch() const const
The Cutelyst namespace holds all public Cutelyst API.
QMimeType mimeTypeForFile(const QFileInfo &fileInfo, QMimeDatabase::MatchMode mode) const const
QString toLower() const const
SkipEmptyParts
virtual qint64 size() const const override
bool isValid() const const
QByteArray ifModifiedSince() const noexcept
Definition: headers.cpp:205
void pushHeader(const QByteArray &key, const QByteArray &value)
Definition: headers.cpp:460
QByteArray contentEncoding() const noexcept
Definition: headers.cpp:57
QString fromLatin1(QByteArrayView str)
QString mid(qsizetype position, qsizetype n) const const
QRegularExpressionMatch match(QStringView subjectView, qsizetype offset, QRegularExpression::MatchType matchType, QRegularExpression::MatchOptions matchOptions) const const
QStringList split(QChar sep, Qt::SplitBehavior behavior, Qt::CaseSensitivity cs) const const
qint64 toSecsSinceEpoch() const const
bool endsWith(QChar c, Qt::CaseSensitivity cs) const const
QByteArray hash(QByteArrayView data, QCryptographicHash::Algorithm method)
bool open(FILE *fh, QIODeviceBase::OpenMode mode, QFileDevice::FileHandleFlags handleFlags)
Base class for Cutelyst Plugins.
Definition: plugin.h:24
The Cutelyst application.
Definition: application.h:72
Engine * engine() const noexcept
void setBody(QIODevice *body)
Definition: response.cpp:103
void setContentLength(qint64 value)
Definition: headers.cpp:172
qsizetype size() const const
QObject * parent() const const
Response * response() const noexcept
Definition: context.cpp:97
QByteArray & remove(qsizetype pos, qsizetype len)
void setStatus(quint16 status) noexcept
Definition: response.cpp:72
QByteArray::iterator end()
QByteArray toUtf8() const const