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