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