Browse Source

Updated

Updated some dev dependencies.

---

Gulp will now build CSS/JS files during development into dist-dev
directory, to prevent IDE's Git from unnecessarily building diff's.

Added dist-dev to ignore files.

---

The entire config fille will now be passed to Nunjuck templates for ease
of access of config values.

Root domain for use in Nunjuck templates will now be parsed from config.

Better page titles.

Updated help message for "Uploads history order" option in
homepage's config tab.

Added "Load images for preview" option to homepage's config tab.
Setting this to false will now prevent image uploads from loading
themselves for previews.

Uploads' original names in homepage's uploads history are now
selectable.

Min/max length for user/pass are now enforced in auth's front-end.

Improved performance of album public pages.
Their generated HTML pages will now be cached into memory.
Unfortunately, No-JS version of their pages will be cached separately,
so each album may take up to double the memory space.

File names in thumbnails no longer have their full URLs as tooltips.
I saw no point in that behavior.

Added video icons.
Homepage's uploads history will now display video icons for videos.

"View thumbnail" button in Dashboard is now renamed to "Show preview".
Their icons will also be changed depending on their file types.

Added max length for albums' title & description.
These will be enforced both in front-end and back-end.
Existing albums that have surpassed the limits will not be enforced.

A few other small improvements.
Bobby Wibowo 4 weeks ago
parent
commit
9e9b0d4439
50 changed files with 429 additions and 267 deletions
  1. 2 1
      .eslintignore
  2. 3 0
      .gitignore
  3. 2 1
      .stylelintignore
  4. 13 7
      controllers/albumsController.js
  5. 34 16
      controllers/authController.js
  6. 3 1
      controllers/pathsController.js
  7. 16 3
      controllers/uploadController.js
  8. 18 5
      controllers/utilsController.js
  9. 1 1
      dist/css/dashboard.css
  10. 1 1
      dist/css/dashboard.css.map
  11. 1 1
      dist/css/style.css
  12. 1 1
      dist/css/style.css.map
  13. 1 1
      dist/css/thumbs.css
  14. 1 1
      dist/css/thumbs.css.map
  15. 1 1
      dist/js/auth.js
  16. 1 1
      dist/js/auth.js.map
  17. 1 1
      dist/js/dashboard.js
  18. 1 1
      dist/js/dashboard.js.map
  19. 1 1
      dist/js/home.js
  20. 1 1
      dist/js/home.js.map
  21. 1 1
      dist/libs/fontello/fontello.css
  22. 1 1
      dist/libs/fontello/fontello.css.map
  23. 14 7
      gulpfile.js
  24. 2 18
      lolisafe.js
  25. 1 1
      package.json
  26. 6 0
      public/libs/fontello/config.json
  27. BIN
      public/libs/fontello/fontello.eot
  28. 2 0
      public/libs/fontello/fontello.svg
  29. BIN
      public/libs/fontello/fontello.ttf
  30. BIN
      public/libs/fontello/fontello.woff
  31. BIN
      public/libs/fontello/fontello.woff2
  32. 54 29
      routes/album.js
  33. 7 25
      routes/nojs.js
  34. 16 1
      src/css/dashboard.css
  35. 0 15
      src/css/style.css
  36. 3 2
      src/css/thumbs.css
  37. 4 1
      src/js/auth.js
  38. 34 17
      src/js/dashboard.js
  39. 38 18
      src/js/home.js
  40. 8 7
      src/libs/fontello/fontello.css
  41. 1 1
      todo.md
  42. 2 3
      views/_globals.njk
  43. 23 15
      views/_layout.njk
  44. 32 21
      views/album.njk
  45. 3 2
      views/auth.njk
  46. 1 0
      views/dashboard.njk
  47. 7 1
      views/faq.njk
  48. 25 6
      views/home.njk
  49. 15 3
      views/nojs.njk
  50. 26 26
      yarn.lock

+ 2 - 1
.eslintignore

@@ -1,4 +1,5 @@
 **/*.min.js
-dist/js/*
+dist/*
+dist-dev/*
 public/libs/*
 src/libs/*

+ 3 - 0
.gitignore

@@ -51,6 +51,9 @@ package-lock.json
 # Custom pages directory
 /pages/custom
 
+# Dist dev
+/dist-dev
+
 # User files
 .DS_Store
 .nvmrc

+ 2 - 1
.stylelintignore

@@ -1,3 +1,4 @@
-dist/css/*
+dist/*
+dist-dev/*
 public/libs/*
 src/libs/*

+ 13 - 7
controllers/albumsController.js

@@ -10,6 +10,11 @@ const logger = require('./../logger')
 const db = require('knex')(config.database)
 
 const self = {
+  // Don't forget to update max length of text inputs in
+  // home.js & dashboard.js when changing these values
+  titleMaxLength: 280,
+  descMaxLength: 4000,
+
   onHold: new Set()
 }
 
@@ -109,7 +114,7 @@ self.create = async (req, res, next) => {
   if (!user) return
 
   const name = typeof req.body.name === 'string'
-    ? utils.escape(req.body.name.trim())
+    ? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
     : ''
 
   if (!name)
@@ -140,7 +145,7 @@ self.create = async (req, res, next) => {
       download: (req.body.download === false || req.body.download === 0) ? 0 : 1,
       public: (req.body.public === false || req.body.public === 0) ? 0 : 1,
       description: typeof req.body.description === 'string'
-        ? utils.escape(req.body.description.trim())
+        ? utils.escape(req.body.description.trim().substring(0, self.descMaxLength))
         : ''
     })
     utils.invalidateStatsCache('albums')
@@ -159,7 +164,7 @@ self.delete = async (req, res, next) => {
 
   const id = req.body.id
   const purge = req.body.purge
-  if (id === undefined || id === '')
+  if (!Number.isFinite(id))
     return res.json({ success: false, description: 'No album specified.' })
 
   try {
@@ -184,7 +189,7 @@ self.delete = async (req, res, next) => {
         userid: user.id
       })
       .update('enabled', 0)
-    utils.invalidateStatsCache('albums')
+    utils.invalidateAlbumsCache([id])
 
     const identifier = await db.table('albums')
       .select('identifier')
@@ -215,7 +220,7 @@ self.edit = async (req, res, next) => {
     return res.json({ success: false, description: 'No album specified.' })
 
   const name = typeof req.body.name === 'string'
-    ? utils.escape(req.body.name.trim())
+    ? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
     : ''
 
   if (!name)
@@ -245,13 +250,14 @@ self.edit = async (req, res, next) => {
       })
       .update({
         name,
+        editedAt: Math.floor(Date.now() / 1000),
         download: Boolean(req.body.download),
         public: Boolean(req.body.public),
         description: typeof req.body.description === 'string'
-          ? utils.escape(req.body.description.trim())
+          ? utils.escape(req.body.description.trim().substring(0, self.descMaxLength))
           : ''
       })
-    utils.invalidateStatsCache('albums')
+    utils.invalidateAlbumsCache([id])
 
     if (!req.body.requestLink)
       return res.json({ success: true, name })

+ 34 - 16
controllers/authController.js

@@ -1,4 +1,3 @@
-const { promisify } = require('util')
 const bcrypt = require('bcrypt')
 const randomstring = require('randomstring')
 const perms = require('./permissionController')
@@ -8,11 +7,27 @@ const config = require('./../config')
 const logger = require('./../logger')
 const db = require('knex')(config.database)
 
+// Don't forget to update min/max length of text inputs in auth.njk
+// when changing these values.
 const self = {
-  compare: promisify(bcrypt.compare),
-  hash: promisify(bcrypt.hash)
+  user: {
+    min: 4,
+    max: 32
+  },
+  pass: {
+    min: 6,
+    // Should not be more than 72 characters
+    // https://github.com/kelektiv/node.bcrypt.js#security-issues-and-concerns
+    max: 64,
+    // Length of randomized password
+    // when resetting passwordthrough Dashboard's Manage Users.
+    rand: 16
+  }
 }
 
+// https://github.com/kelektiv/node.bcrypt.js#a-note-on-rounds
+const saltRounds = 10
+
 self.verify = async (req, res, next) => {
   const username = typeof req.body.username === 'string'
     ? req.body.username.trim()
@@ -37,7 +52,7 @@ self.verify = async (req, res, next) => {
     if (user.enabled === false || user.enabled === 0)
       return res.json({ success: false, description: 'This account has been disabled.' })
 
-    const result = await self.compare(password, user.password)
+    const result = await bcrypt.compare(password, user.password)
     if (result === false)
       return res.json({ success: false, description: 'Wrong password.' })
     else
@@ -55,14 +70,14 @@ self.register = async (req, res, next) => {
   const username = typeof req.body.username === 'string'
     ? req.body.username.trim()
     : ''
-  if (username.length < 4 || username.length > 32)
-    return res.json({ success: false, description: 'Username must have 4-32 characters.' })
+  if (username.length < self.user.min || username.length > self.user.max)
+    return res.json({ success: false, description: `Username must have ${self.user.min}-${self.user.max} characters.` })
 
   const password = typeof req.body.password === 'string'
     ? req.body.password.trim()
     : ''
-  if (password.length < 6 || password.length > 64)
-    return res.json({ success: false, description: 'Password must have 6-64 characters.' })
+  if (password.length < self.pass.min || password.length > self.pass.max)
+    return res.json({ success: false, description: `Password must have ${self.pass.min}-${self.pass.max} characters.` })
 
   try {
     const user = await db.table('users')
@@ -72,7 +87,7 @@ self.register = async (req, res, next) => {
     if (user)
       return res.json({ success: false, description: 'Username already exists.' })
 
-    const hash = await self.hash(password, 10)
+    const hash = await bcrypt.hash(password, saltRounds)
 
     const token = await tokens.generateUniqueToken()
     if (!token)
@@ -103,11 +118,11 @@ self.changePassword = async (req, res, next) => {
   const password = typeof req.body.password === 'string'
     ? req.body.password.trim()
     : ''
-  if (password.length < 6 || password.length > 64)
-    return res.json({ success: false, description: 'Password must have 6-64 characters.' })
+  if (password.length < self.pass.min || password.length > self.pass.max)
+    return res.json({ success: false, description: `Password must have ${self.pass.min}-${self.pass.max} characters.` })
 
   try {
-    const hash = await self.hash(password, 10)
+    const hash = await bcrypt.hash(password, saltRounds)
 
     await db.table('users')
       .where('id', user.id)
@@ -144,8 +159,11 @@ self.editUser = async (req, res, next) => {
 
     if (req.body.username !== undefined) {
       update.username = String(req.body.username).trim()
-      if (update.username.length < 4 || update.username.length > 32)
-        return res.json({ success: false, description: 'Username must have 4-32 characters.' })
+      if (update.username.length < self.user.min || update.username.length > self.user.max)
+        return res.json({
+          success: false,
+          description: `Username must have ${self.user.min}-${self.user.max} characters.`
+        })
     }
 
     if (req.body.enabled !== undefined)
@@ -159,8 +177,8 @@ self.editUser = async (req, res, next) => {
 
     let password
     if (req.body.resetPassword) {
-      password = randomstring.generate(16)
-      update.password = await self.hash(password, 10)
+      password = randomstring.generate(self.pass.rand)
+      update.password = await bcrypt.hash(password, saltRounds)
     }
 
     await db.table('users')

+ 3 - 1
controllers/pathsController.js

@@ -33,7 +33,9 @@ self.thumbPlaceholder = path.resolve(config.uploads.generateThumbs.placeholder |
 self.logs = path.resolve(config.logsFolder)
 
 self.customPages = path.resolve('pages/custom')
-self.dist = path.resolve('dist')
+self.dist = process.env.NODE_ENV === 'development'
+  ? path.resolve('dist-dev')
+  : path.resolve('dist')
 self.public = path.resolve('public')
 
 self.errorRoot = path.resolve(config.errorPages.rootDir)

+ 16 - 3
controllers/uploadController.js

@@ -51,8 +51,18 @@ const initChunks = async uuid => {
 }
 
 const executeMulter = multer({
+  // Guide: https://github.com/expressjs/multer#limits
   limits: {
-    fileSize: maxSizeBytes
+    fileSize: maxSizeBytes,
+    // Maximum number of non-file fields.
+    // Dropzone.js will add 6 extra fields for chunked uploads.
+    // We don't use them for anything else.
+    fields: 6,
+    // Maximum number of file fields.
+    // Chunked uploads still need to provide only 1 file field.
+    // Otherwise, only one of the files will end up being properly stored,
+    // and that will also be as a chunk.
+    files: 20
   },
   fileFilter (req, file, cb) {
     file.extname = utils.extname(file.originalname)
@@ -101,7 +111,8 @@ const executeMulter = multer({
       return cb(null, name)
     }
   })
-}).array('files[]')
+}).array('files[]', {
+})
 
 self.isExtensionFiltered = extname => {
   // If empty extension needs to be filtered
@@ -621,10 +632,12 @@ self.storeFilesToDb = async (req, res, user, infoMap) => {
     utils.invalidateStatsCache('uploads')
 
     // Update albums' timestamp
-    if (authorizedIds.length)
+    if (authorizedIds.length) {
       await db.table('albums')
         .whereIn('id', authorizedIds)
         .update('editedAt', Math.floor(Date.now() / 1000))
+      utils.invalidateAlbumsCache(authorizedIds)
+    }
   }
 
   return files.concat(exists)

+ 18 - 5
controllers/utilsController.js

@@ -25,7 +25,9 @@ const self = {
   imageExts: ['.webp', '.jpg', '.jpeg', '.gif', '.png', '.tiff', '.tif', '.svg'],
   videoExts: ['.webm', '.mp4', '.wmv', '.avi', '.mov', '.mkv'],
 
-  ffprobe: promisify(ffmpeg.ffprobe)
+  ffprobe: promisify(ffmpeg.ffprobe),
+
+  albumsCache: {}
 }
 
 const statsCache = {
@@ -57,7 +59,7 @@ const statsCache = {
   }
 }
 
-const cloudflareAuth = config.cloudflare.apiKey && config.cloudflare.email && config.cloudflare.zoneId
+const cloudflareAuth = config.cloudflare && config.cloudflare.apiKey && config.cloudflare.email && config.cloudflare.zoneId
 
 self.mayGenerateThumb = extname => {
   return (config.uploads.generateThumbs.image && self.imageExts.includes(extname)) ||
@@ -504,6 +506,14 @@ self.bulkDeleteExpired = async (dryrun) => {
   return result
 }
 
+self.invalidateAlbumsCache = albumids => {
+  for (const albumid of albumids) {
+    delete self.albumsCache[albumid]
+    delete self.albumsCache[`${albumid}-nojs`]
+  }
+  self.invalidateStatsCache('albums')
+}
+
 self.invalidateStatsCache = type => {
   if (!['albums', 'users', 'uploads'].includes(type)) return
   statsCache[type].invalidatedAt = Date.now()
@@ -660,6 +670,8 @@ self.stats = async (req, res, next) => {
       stats.uploads = statsCache.uploads.cache
     } else {
       statsCache.uploads.generating = true
+      statsCache.uploads.generatedAt = Date.now()
+
       stats.uploads = {
         _types: {
           number: ['total', 'images', 'videos', 'others']
@@ -700,7 +712,6 @@ self.stats = async (req, res, next) => {
 
       // Update cache
       statsCache.uploads.cache = stats.uploads
-      statsCache.uploads.generatedAt = Date.now()
       statsCache.uploads.generating = false
     }
 
@@ -711,6 +722,8 @@ self.stats = async (req, res, next) => {
       stats.users = statsCache.users.cache
     } else {
       statsCache.users.generating = true
+      statsCache.users.generatedAt = Date.now()
+
       stats.users = {
         _types: {
           number: ['total', 'disabled']
@@ -742,7 +755,6 @@ self.stats = async (req, res, next) => {
 
       // Update cache
       statsCache.users.cache = stats.users
-      statsCache.users.generatedAt = Date.now()
       statsCache.users.generating = false
     }
 
@@ -753,6 +765,8 @@ self.stats = async (req, res, next) => {
       stats.albums = statsCache.albums.cache
     } else {
       statsCache.albums.generating = true
+      statsCache.albums.generatedAt = Date.now()
+
       stats.albums = {
         _types: {
           number: ['total', 'active', 'downloadable', 'public', 'generatedZip']
@@ -789,7 +803,6 @@ self.stats = async (req, res, next) => {
 
       // Update cache
       statsCache.albums.cache = stats.albums
-      statsCache.albums.generatedAt = Date.now()
       statsCache.albums.generating = false
     }
 

File diff suppressed because it is too large
+ 1 - 1
dist/css/dashboard.css


File diff suppressed because it is too large
+ 1 - 1
dist/css/dashboard.css.map


File diff suppressed because it is too large
+ 1 - 1
dist/css/style.css


File diff suppressed because it is too large
+ 1 - 1
dist/css/style.css.map


File diff suppressed because it is too large
+ 1 - 1
dist/css/thumbs.css


File diff suppressed because it is too large
+ 1 - 1
dist/css/thumbs.css.map


File diff suppressed because it is too large
+ 1 - 1
dist/js/auth.js


File diff suppressed because it is too large
+ 1 - 1
dist/js/auth.js.map


File diff suppressed because it is too large
+ 1 - 1
dist/js/dashboard.js


File diff suppressed because it is too large
+ 1 - 1
dist/js/dashboard.js.map


File diff suppressed because it is too large
+ 1 - 1
dist/js/home.js


File diff suppressed because it is too large
+ 1 - 1
dist/js/home.js.map


File diff suppressed because it is too large
+ 1 - 1
dist/libs/fontello/fontello.css


File diff suppressed because it is too large
+ 1 - 1
dist/libs/fontello/fontello.css.map


+ 14 - 7
gulpfile.js

@@ -11,6 +11,13 @@ const sourcemaps = require('gulp-sourcemaps')
 const stylelint = require('gulp-stylelint')
 const terser = require('gulp-terser')
 
+// Put built files for development on a Git-ignored directory.
+// This will prevent IDE's Git from unnecessarily
+// building diff's during development.
+const dist = process.env.NODE_ENV === 'development'
+  ? './dist-dev'
+  : './dist'
+
 /** TASKS: LINT */
 
 gulp.task('lint:js', () => {
@@ -34,21 +41,21 @@ gulp.task('lint', gulp.parallel('lint:js', 'lint:css'))
 
 gulp.task('clean:css', () => {
   return del([
-    './dist/**/*.css',
-    './dist/**/*.css.map'
+    `${dist}/**/*.css`,
+    `${dist}/**/*.css.map`
   ])
 })
 
 gulp.task('clean:js', () => {
   return del([
-    './dist/**/*.js',
-    './dist/**/*.js.map'
+    `${dist}/**/*.js`,
+    `${dist}/**/*.js.map`
   ])
 })
 
 gulp.task('clean:rest', () => {
   return del([
-    './dist/*'
+    `${dist}/*`
   ])
 })
 
@@ -69,7 +76,7 @@ gulp.task('build:css', () => {
     .pipe(sourcemaps.init())
     .pipe(postcss(plugins))
     .pipe(sourcemaps.write('.'))
-    .pipe(gulp.dest('./dist'))
+    .pipe(gulp.dest(dist))
 })
 
 gulp.task('build:js', () => {
@@ -79,7 +86,7 @@ gulp.task('build:js', () => {
     // Minify on production
     .pipe(gulpif(process.env.NODE_ENV !== 'development', terser()))
     .pipe(sourcemaps.write('.'))
-    .pipe(gulp.dest('./dist'))
+    .pipe(gulp.dest(dist))
 })
 
 gulp.task('build', gulp.parallel('build:css', 'build:js'))

+ 2 - 18
lolisafe.js

@@ -117,25 +117,9 @@ safe.use('/api', api)
       if (!await paths.access(customPage).catch(() => true))
         safe.get(`/${page === 'home' ? '' : page}`, (req, res, next) => res.sendFile(customPage))
       else if (page === 'home')
-        safe.get('/', (req, res, next) => res.render('home', {
-          maxSize: parseInt(config.uploads.maxSize),
-          urlMaxSize: parseInt(config.uploads.urlMaxSize),
-          urlDisclaimerMessage: config.uploads.urlDisclaimerMessage,
-          urlExtensionsFilterMode: config.uploads.urlExtensionsFilterMode,
-          urlExtensionsFilter: config.uploads.urlExtensionsFilter,
-          temporaryUploadAges: Array.isArray(config.uploads.temporaryUploadAges) &&
-            config.uploads.temporaryUploadAges.length,
-          gitHash: utils.gitHash
-        }))
-      else if (page === 'faq')
-        safe.get('/faq', (req, res, next) => res.render('faq', {
-          whitelist: config.extensionsFilterMode === 'whitelist',
-          extensionsFilter: config.extensionsFilter,
-          noJsMaxSize: parseInt(config.cloudflare.noJsMaxSize) < parseInt(config.uploads.maxSize),
-          chunkSize: parseInt(config.uploads.chunkSize)
-        }))
+        safe.get('/', (req, res, next) => res.render(page, { config, gitHash: utils.gitHash }))
       else
-        safe.get(`/${page}`, (req, res, next) => res.render(page))
+        safe.get(`/${page}`, (req, res, next) => res.render(page, { config }))
     }
 
     // Error pages

+ 1 - 1
package.json

@@ -68,6 +68,6 @@
     "gulp-terser": "^1.2.0",
     "postcss-preset-env": "^6.7.0",
     "stylelint": "^10.1.0",
-    "stylelint-config-standard": "^18.3.0"
+    "stylelint-config-standard": "^19.0.0"
   }
 }

+ 6 - 0
public/libs/fontello/config.json

@@ -235,6 +235,12 @@
       "search": [
         "gatsby"
       ]
+    },
+    {
+      "uid": "31accb20e8819b200c297df608e68830",
+      "css": "video",
+      "code": 59404,
+      "src": "elusive"
     }
   ]
 }

BIN
public/libs/fontello/fontello.eot


File diff suppressed because it is too large
+ 2 - 0
public/libs/fontello/fontello.svg


BIN
public/libs/fontello/fontello.ttf


BIN
public/libs/fontello/fontello.woff


BIN
public/libs/fontello/fontello.woff2


+ 54 - 29
routes/album.js

@@ -5,8 +5,6 @@ const utils = require('./../controllers/utilsController')
 const config = require('./../config')
 const db = require('knex')(config.database)
 
-const homeDomain = config.homeDomain || config.domain
-
 routes.get('/a/:identifier', async (req, res, next) => {
   const identifier = req.params.identifier
   if (identifier === undefined)
@@ -20,6 +18,7 @@ routes.get('/a/:identifier', async (req, res, next) => {
       identifier,
       enabled: 1
     })
+    .select('id', 'name', 'identifier', 'editedAt', 'download', 'public', 'description')
     .first()
 
   if (!album)
@@ -30,44 +29,70 @@ routes.get('/a/:identifier', async (req, res, next) => {
       description: 'This album is not available for public.'
     })
 
+  const nojs = req.query.nojs !== undefined
+
+  // Cache ID - we initialize a separate cache for No-JS version
+  const cacheid = nojs ? `${album.id}-nojs` : album.id
+
+  if (!utils.albumsCache[cacheid])
+    utils.albumsCache[cacheid] = {
+      cache: null,
+      generating: false,
+      // Cache will actually be deleted after the album has been updated,
+      // so storing this timestamp may be redundant, but just in case.
+      generatedAt: 0
+    }
+
+  if (!utils.albumsCache[cacheid].cache && utils.albumsCache[cacheid].generating)
+    return res.json({
+      success: false,
+      description: 'This album is still generating its public page.'
+    })
+  else if ((album.editedAt < utils.albumsCache[cacheid].generatedAt) || utils.albumsCache[cacheid].generating)
+    return res.send(utils.albumsCache[cacheid].cache)
+
+  // Use current timestamp to make sure cache is invalidated
+  // when an album is edited during this generation process.
+  utils.albumsCache[cacheid].generating = true
+  utils.albumsCache[cacheid].generatedAt = Math.floor(Date.now() / 1000)
+
   const files = await db.table('files')
     .select('name', 'size')
     .where('albumid', album.id)
     .orderBy('id', 'DESC')
 
-  let thumb = ''
-  const basedomain = config.domain
+  album.thumb = ''
+  album.totalSize = 0
 
-  let totalSize = 0
   for (const file of files) {
-    file.file = `${basedomain}/${file.name}`
-    file.extname = path.extname(file.name).toLowerCase()
+    album.totalSize += parseInt(file.size)
+
+    file.extname = path.extname(file.name)
     if (utils.mayGenerateThumb(file.extname)) {
-      file.thumb = `${basedomain}/thumbs/${file.name.slice(0, -file.extname.length)}.png`
-      /*
-        If thumbnail for album is still not set, set it to current file's full URL.
-        A potential improvement would be to let the user set a specific image as an album cover.
-      */
-      if (thumb === '') thumb = file.file
+      file.thumb = `thumbs/${file.name.slice(0, -file.extname.length)}.png`
+      // If thumbnail for album is still not set, set it to current file's full URL.
+      // A potential improvement would be to let the user set a specific image as an album cover.
+      if (!album.thumb) album.thumb = file.name
     }
-    totalSize += parseInt(file.size)
   }
 
-  return res.render('album', {
-    title: album.name,
-    description: album.description ? album.description.replace(/\n/g, '<br>') : null,
-    count: files.length,
-    thumb,
-    files,
-    identifier,
-    generateZips: config.uploads.generateZips,
-    downloadLink: album.download === 0
-      ? null
-      : `${homeDomain}/api/album/zip/${album.identifier}?v=${album.editedAt}`,
-    editedAt: album.editedAt,
-    url: `${homeDomain}/a/${album.identifier}`,
-    totalSize,
-    nojs: req.query.nojs !== undefined
+  album.description = album.description
+    ? album.description.replace(/\n/g, '<br>')
+    : null
+
+  album.downloadLink = album.download === 0
+    ? null
+    : `api/album/zip/${album.identifier}?v=${album.editedAt}`
+
+  album.url = `a/${album.identifier}`
+
+  return res.render('album', { config, album, files, nojs }, (error, html) => {
+    utils.albumsCache[cacheid].cache = error ? null : html
+    utils.albumsCache[cacheid].generating = false
+
+    // Express should already send error to the next handler
+    if (error) return
+    return res.send(utils.albumsCache[cacheid].cache)
   })
 })
 

+ 7 - 25
routes/nojs.js

@@ -3,39 +3,21 @@ const uploadController = require('./../controllers/uploadController')
 const utils = require('./../controllers/utilsController')
 const config = require('./../config')
 
-const renderOptions = {
-  uploadDisabled: false,
-  maxFileSize: parseInt(config.cloudflare.noJsMaxSize || config.uploads.maxSize)
-}
-
-if (config.private)
-  if (config.enableUserAccounts) {
-    renderOptions.uploadDisabled = 'Anonymous upload is disabled.'
-  } else {
-    renderOptions.uploadDisabled = 'Running in private mode.'
-  }
-
 routes.get('/nojs', async (req, res, next) => {
-  const options = { renderOptions }
-  options.gitHash = utils.gitHash
-
-  return res.render('nojs', options)
+  return res.render('nojs', { config, gitHash: utils.gitHash })
 })
 
 routes.post('/nojs', (req, res, next) => {
   res._json = res.json
   res.json = (...args) => {
     const result = args[0]
-
-    const options = { renderOptions }
-    options.gitHash = utils.utils
-
-    options.errorMessage = result.success ? '' : (result.description || 'An unexpected error occurred.')
-    options.files = result.files || [{}]
-
-    return res.render('nojs', options)
+    return res.render('nojs', {
+      config,
+      gitHash: utils.gitHash,
+      errorMessage: result.success ? '' : (result.description || 'An unexpected error occurred.'),
+      files: result.files || [{}]
+    })
   }
-
   return uploadController.upload(req, res, next)
 })
 

+ 16 - 1
src/css/dashboard.css

@@ -33,6 +33,21 @@ body {
   background: none
 }
 
+.menu-list a.is-loading::after {
+  animation: spinAround 0.5s infinite linear;
+  border: 2px solid #dbdbdb;
+  border-radius: 290486px;
+  border-right-color: transparent;
+  border-top-color: transparent;
+  content: "";
+  display: block;
+  height: 1em;
+  width: 1em;
+  right: calc(0% + (1em / 2));
+  top: calc(50% - (1em / 2));
+  position: absolute !important
+}
+
 ul#albumsContainer {
   border-left: 0;
   padding-left: 0
@@ -155,7 +170,7 @@ li[data-action="page-ellipsis"] {
   text-decoration: line-through
 }
 
-#menu.is-loading li a {
+#menu.is-loading .menu-list a {
   cursor: progress
 }
 

+ 0 - 15
src/css/style.css

@@ -124,21 +124,6 @@ code,
   box-shadow: 0 20px 60px rgba(10, 10, 10, 0.05), 0 5px 10px rgba(10, 10, 10, 0.1), 0 1px 1px rgba(10, 10, 10, 0.2)
 }
 
-.menu-list a.is-loading::after {
-  animation: spinAround 0.5s infinite linear;
-  border: 2px solid #dbdbdb;
-  border-radius: 290486px;
-  border-right-color: transparent;
-  border-top-color: transparent;
-  content: "";
-  display: block;
-  height: 1em;
-  width: 1em;
-  right: calc(0% + (1em / 2));
-  top: calc(50% - (1em / 2));
-  position: absolute !important
-}
-
 /* https://github.com/philipwalton/flexbugs#flexbug-3 */
 .hero.is-fullheight > .hero-body {
   min-height: 100vh;

+ 3 - 2
src/css/thumbs.css

@@ -1,8 +1,9 @@
 .image-container {
-  display: flex;
+  flex: none;
+  position: relative;
   width: 224px;
   height: 224px;
-  margin: 9px;
+  margin: 0.75rem;
   padding: 12px;
   background-color: #31363b;
   overflow: hidden;

+ 4 - 1
src/js/auth.js

@@ -67,15 +67,18 @@ window.onload = () => {
   page.pass = document.querySelector('#pass')
 
   // Prevent default form's submit action
-  document.querySelector('#authForm').addEventListener('submit', event => {
+  const form = document.querySelector('#authForm')
+  form.addEventListener('submit', event => {
     event.preventDefault()
   })
 
   document.querySelector('#loginBtn').addEventListener('click', event => {
+    if (!form.checkValidity()) return
     page.do('login', event.currentTarget)
   })
 
   document.querySelector('#registerBtn').addEventListener('click', event => {
+    if (!form.checkValidity()) return
     page.do('register', event.currentTarget)
   })
 }

+ 34 - 17
src/js/dashboard.js

@@ -84,7 +84,10 @@ const page = {
   videoExts: ['.webm', '.mp4', '.wmv', '.avi', '.mov', '.mkv'],
 
   isTriggerLoading: null,
-  fadingIn: null
+  fadingIn: null,
+
+  albumTitleMaxLength: 280,
+  albumDescMaxLength: 4000
 }
 
 page.preparePage = () => {
@@ -270,8 +273,8 @@ page.domClick = event => {
       return page.deleteUpload(id)
     case 'bulk-delete-uploads':
       return page.bulkDeleteUploads()
-    case 'display-thumbnail':
-      return page.displayThumbnail(id)
+    case 'display-preview':
+      return page.displayPreview(id)
     case 'submit-album':
       return page.submitAlbum(element)
     case 'edit-album':
@@ -495,11 +498,21 @@ page.getUploads = (params = {}) => {
       if (files[i].thumb)
         files[i].thumb = `${basedomain}/${files[i].thumb}`
 
+      // Determine types
+      files[i].type = 'other'
+      const exec = /.[\w]+(\?|$)/.exec(files[i].file)
+      const extname = exec && exec[0] ? exec[0].toLowerCase() : null
+      if (page.imageExts.includes(extname))
+        files[i].type = 'picture'
+      else if (page.videoExts.includes(extname))
+        files[i].type = 'video'
+
       // Cache bare minimum data for thumbnails viewer
       page.cache.uploads[files[i].id] = {
         name: files[i].name,
         thumb: files[i].thumb,
-        original: files[i].file
+        original: files[i].file,
+        type: files[i].type
       }
 
       // Prettify
@@ -542,7 +555,7 @@ page.getUploads = (params = {}) => {
       for (let i = 0; i < files.length; i++) {
         const upload = files[i]
         const div = document.createElement('div')
-        div.className = 'image-container column is-narrow is-relative'
+        div.className = 'image-container column'
         div.dataset.id = upload.id
 
         if (upload.thumb !== undefined)
@@ -554,9 +567,9 @@ page.getUploads = (params = {}) => {
           <input type="checkbox" class="checkbox" title="Select" data-index="${i}" data-action="select"${upload.selected ? ' checked' : ''}>
           <div class="controls">
             ${upload.thumb ? `
-            <a class="button is-small is-primary" title="View thumbnail" data-action="display-thumbnail">
+            <a class="button is-small is-primary" title="Display preview" data-action="display-preview">
               <span class="icon">
-                <i class="icon-picture"></i>
+                <i class="${upload.type !== 'other' ? `icon-${upload.type}` : 'icon-doc-inv'}"></i>
               </span>
             </a>` : ''}
             <a class="button is-small is-info clipboard-js" title="Copy link to clipboard" data-clipboard-text="${upload.file}">
@@ -576,7 +589,7 @@ page.getUploads = (params = {}) => {
             </a>
           </div>
           <div class="details">
-            <p><span class="name" title="${upload.file}">${upload.name}</span></p>
+            <p><span class="name">${upload.name}</span></p>
             <p>${upload.appendix ? `<span>${upload.appendix}</span> – ` : ''}${upload.prettyBytes}</p>
             ${hasExpiryDateColumn && upload.prettyExpiryDate ? `
             <p class="expirydate">EXP: ${upload.prettyExpiryDate}</p>` : ''}
@@ -629,9 +642,9 @@ page.getUploads = (params = {}) => {
           <td>${upload.prettyDate}</td>
           ${hasExpiryDateColumn ? `<td>${upload.prettyExpiryDate || '-'}</td>` : ''}
           <td class="controls has-text-right">
-            <a class="button is-small is-primary" title="${upload.thumb ? 'View thumbnail' : 'File doesn\'t have thumbnail'}" data-action="display-thumbnail"${upload.thumb ? '' : ' disabled'}>
+            <a class="button is-small is-primary" title="${upload.thumb ? 'Display preview' : 'File can\'t be previewed'}" data-action="display-preview"${upload.thumb ? '' : ' disabled'}>
               <span class="icon">
-                <i class="icon-picture"></i>
+                <i class="${upload.type !== 'other' ? `icon-${upload.type}` : 'icon-doc-inv'}"></i>
               </span>
             </a>
             <a class="button is-small is-info clipboard-js" title="Copy link to clipboard" data-clipboard-text="${upload.file}">
@@ -688,7 +701,7 @@ page.setUploadsView = (view, element) => {
   }, page.views[page.currentView]))
 }
 
-page.displayThumbnail = id => {
+page.displayPreview = id => {
   const file = page.cache.uploads[id]
   if (!file.thumb) return
 
@@ -1254,13 +1267,15 @@ page.getAlbums = (params = {}) => {
       <form class="prevent-default">
         <div class="field">
           <div class="control">
-            <input id="albumName" class="input" type="text" placeholder="Name">
+            <input id="albumName" class="input" type="text" placeholder="Name" maxlength="${page.albumTitleMaxLength}">
           </div>
+          <p class="help">Max length is ${page.albumTitleMaxLength} characters.</p>
         </div>
         <div class="field">
           <div class="control">
-            <textarea id="albumDescription" class="textarea" placeholder="Description" rows="1"></textarea>
+            <textarea id="albumDescription" class="textarea" placeholder="Description" rows="1" maxlength="${page.albumDescMaxLength}"></textarea>
           </div>
+          <p class="help">Max length is ${page.albumDescMaxLength} characters.</p>
         </div>
         <div class="field">
           <div class="control">
@@ -1360,13 +1375,15 @@ page.editAlbum = id => {
   div.innerHTML = `
     <div class="field">
       <div class="controls">
-        <input id="swalName" class="input" type="text" placeholder="Name" value="${album.name || ''}">
+        <input id="swalName" class="input" type="text" placeholder="Name" maxlength="${page.albumTitleMaxLength}" value="${(album.name || '').substring(0, page.albumTitleMaxLength)}">
       </div>
+      <p class="help">Max length is ${page.albumTitleMaxLength} characters.</p>
     </div>
     <div class="field">
       <div class="control">
-        <textarea id="swalDescription" class="textarea" placeholder="Description" rows="2">${album.description || ''}</textarea>
+        <textarea id="swalDescription" class="textarea" placeholder="Description" rows="2" maxlength="${page.albumDescMaxLength}">${(album.description || '').substring(0, page.albumDescMaxLength)}</textarea>
       </div>
+      <p class="help">Max length is ${page.albumDescMaxLength} characters.</p>
     </div>
     <div class="field">
       <div class="control">
@@ -1488,8 +1505,8 @@ page.deleteAlbum = id => {
 page.submitAlbum = element => {
   page.updateTrigger(element, 'loading')
   axios.post('api/albums', {
-    name: document.querySelector('#albumName').value,
-    description: document.querySelector('#albumDescription').value
+    name: document.querySelector('#albumName').value.trim(),
+    description: document.querySelector('#albumDescription').value.trim()
   }).then(response => {
     if (!response) return
 

+ 38 - 18
src/js/home.js

@@ -5,6 +5,7 @@ const lsKeys = {
   chunkSize: 'chunkSize',
   parallelUploads: 'parallelUploads',
   uploadsHistoryOrder: 'uploadsHistoryOrder',
+  previewImages: 'previewImages',
   fileLength: 'fileLength',
   uploadAge: 'uploadAge'
 }
@@ -25,7 +26,7 @@ const page = {
   album: null,
 
   parallelUploads: null,
-  uploadsHistoryOrder: null,
+  previewImages: null,
   fileLength: null,
   uploadAge: null,
 
@@ -42,7 +43,13 @@ const page = {
   clipboardJS: null,
   lazyLoad: null,
 
-  imageExtensions: ['.webp', '.jpg', '.jpeg', '.bmp', '.gif', '.png', '.svg']
+  // Include BMP for uploads preview only, cause the real images will be used
+  // Sharp isn't capable of making their thumbnails for dashboard and album public pages
+  imageExts: ['.webp', '.jpg', '.jpeg', '.bmp', '.gif', '.png', '.tiff', '.tif', '.svg'],
+  videoExts: ['.webm', '.mp4', '.wmv', '.avi', '.mov', '.mkv'],
+
+  albumTitleMaxLength: 280,
+  albumDescMaxLength: 4000
 }
 
 // Error handler for all API requests on init
@@ -471,25 +478,34 @@ page.updateTemplate = (file, response) => {
   clipboard.parentElement.classList.remove('is-hidden')
 
   const exec = /.[\w]+(\?|$)/.exec(response.url)
-  if (exec && exec[0] && page.imageExtensions.includes(exec[0].toLowerCase())) {
-    const img = file.previewElement.querySelector('img')
-    img.setAttribute('alt', response.name || '')
-    img.dataset.src = response.url
-    img.classList.remove('is-hidden')
-    img.onerror = event => {
-      // Hide image elements that fail to load
-      // Consequently include WEBP in browsers that do not have WEBP support (e.i. IE)
-      event.currentTarget.classList.add('is-hidden')
+  const extname = exec && exec[0]
+    ? exec[0].toLowerCase()
+    : null
+
+  if (page.imageExts.includes(extname))
+    if (page.previewImages) {
+      const img = file.previewElement.querySelector('img')
+      img.setAttribute('alt', response.name || '')
+      img.dataset.src = response.url
+      img.classList.remove('is-hidden')
+      img.onerror = event => {
+        // Hide image elements that fail to load
+        // Consequently include WEBP in browsers that do not have WEBP support (e.i. IE)
+        event.currentTarget.classList.add('is-hidden')
+        page.updateTemplateIcon(file.previewElement, 'icon-picture')
+      }
+      page.lazyLoad.update(file.previewElement.querySelectorAll('img'))
+    } else {
       page.updateTemplateIcon(file.previewElement, 'icon-picture')
     }
-    page.lazyLoad.update(file.previewElement.querySelectorAll('img'))
-  } else {
+  else if (page.videoExts.includes(extname))
+    page.updateTemplateIcon(file.previewElement, 'icon-video')
+  else
     page.updateTemplateIcon(file.previewElement, 'icon-doc-inv')
-  }
 
   if (response.expirydate) {
     const expiryDate = file.previewElement.querySelector('.expiry-date')
-    expiryDate.innerHTML = `Expiry date: ${page.getPrettyDate(new Date(response.expirydate * 1000))}`
+    expiryDate.innerHTML = `EXP: ${page.getPrettyDate(new Date(response.expirydate * 1000))}`
     expiryDate.classList.remove('is-hidden')
   }
 }
@@ -499,13 +515,15 @@ page.createAlbum = () => {
   div.innerHTML = `
     <div class="field">
       <div class="controls">
-        <input id="swalName" class="input" type="text" placeholder="Name">
+        <input id="swalName" class="input" type="text" placeholder="Name" maxlength="${page.albumTitleMaxLength}">
       </div>
+      <p class="help">Max length is ${page.albumTitleMaxLength} characters.</p>
     </div>
     <div class="field">
       <div class="control">
-        <textarea id="swalDescription" class="textarea" placeholder="Description" rows="2"></textarea>
+        <textarea id="swalDescription" class="textarea" placeholder="Description" rows="2" maxlength="${page.albumDescMaxLength}"></textarea>
       </div>
+      <p class="help">Max length is ${page.albumDescMaxLength} characters.</p>
     </div>
     <div class="field">
       <div class="control">
@@ -665,11 +683,13 @@ page.prepareUploadConfig = () => {
       uploadFields[i].classList.add('is-reversed')
   }
 
+  page.previewImages = localStorage[lsKeys.previewImages] !== '0'
+
   document.querySelector('#saveConfig').addEventListener('click', () => {
     if (!form.checkValidity())
       return
 
-    const prefKeys = ['siBytes', 'uploadsHistoryOrder', 'uploadAge']
+    const prefKeys = ['siBytes', 'uploadsHistoryOrder', 'previewImages', 'uploadAge']
     for (let i = 0; i < prefKeys.length; i++) {
       const value = form.elements[prefKeys[i]].value
       if (value !== 'default' && value !== fallback[prefKeys[i]])

+ 8 - 7
src/libs/fontello/fontello.css

@@ -1,12 +1,12 @@
 @font-face {
   font-family: 'fontello';
-  src: url('fontello.eot?fFS2CGH95j');
+  src: url('fontello.eot?iDzQ0dov5j');
   src:
-    url('fontello.eot?fFS2CGH95j#iefix') format('embedded-opentype'),
-    url('fontello.woff2?fFS2CGH95j') format('woff2'),
-    url('fontello.woff?fFS2CGH95j') format('woff'),
-    url('fontello.ttf?fFS2CGH95j') format('truetype'),
-    url('fontello.svg?fFS2CGH95j#fontello') format('svg');
+    url('fontello.eot?iDzQ0dov5j#iefix') format('embedded-opentype'),
+    url('fontello.woff2?iDzQ0dov5j') format('woff2'),
+    url('fontello.woff?iDzQ0dov5j') format('woff'),
+    url('fontello.ttf?iDzQ0dov5j') format('truetype'),
+    url('fontello.svg?iDzQ0dov5j#fontello') format('svg');
   font-weight: normal;
   font-style: normal
 }
@@ -17,7 +17,7 @@
 @media screen and (-webkit-min-device-pixel-ratio:0) {
   @font-face {
     font-family: 'fontello';
-    src: url('fontello.svg?fFS2CGH95j#fontello') format('svg');
+    src: url('fontello.svg?iDzQ0dov5j#fontello') format('svg');
   }
 }
 */
@@ -73,6 +73,7 @@
 .icon-login::before { content: '\e809' } /* '' */
 .icon-home::before { content: '\e80a' } /* '' */
 .icon-gauge::before { content: '\e80b' } /* '' */
+.icon-video:before { content: '\e80c'; } /* '' */
 .icon-help-circled::before { content: '\e80d' } /* '' */
 .icon-github-circled::before { content: '\e80e' } /* '' */
 .icon-pencil::before { content: '\e80f' } /* '' */

+ 1 - 1
todo.md

@@ -2,7 +2,7 @@
 
 Normal priority:
 
-* [ ] Improve performance of album public pages, and maybe paginate them.
+* [x] Improve performance of album public pages, ~~and maybe paginate them~~.
 * [x] Use [native lazy-load tag](https://web.dev/native-lazy-loading) on nojs album pages.
 * [ ] Use incremental version numbering instead of randomized strings.
 * [ ] Use versioning in APIs, somehow.

+ 2 - 3
views/_globals.njk

@@ -1,5 +1,4 @@
 {% set name = "safe.fiery.me" %}
-{% set root = "https://safe.fiery.me" %}
 {% set motto = "A small safe worth protecting." %}
 {% set description = "A pomf-like file uploading service that doesn't suck." %}
 {% set keywords = "upload,lolisafe,file,images,hosting,bobby,fiery" %}
@@ -16,9 +15,9 @@
   v3: CSS and JS files (libs such as bulma, lazyload, etc).
   v4: Renders in /public/render/* directories (to be used by render.js).
 #}
-{% set v1 = "GqfmYYUziT" %}
+{% set v1 = "iDzQ0dov5j" %}
 {% set v2 = "hiboQUzAzp" %}
-{% set v3 = "GqfmYYUziT" %}
+{% set v3 = "iDzQ0dov5j" %}
 {% set v4 = "S3TAWpPeFS" %}
 
 {#

+ 23 - 15
views/_layout.njk

@@ -1,4 +1,8 @@
 {% import '_globals.njk' as globals %}
+
+{# Set root domain here to inherit values from config file #}
+{% set root = config.homeDomain or config.domain %}
+
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -8,7 +12,11 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
 
-    <title>{{ title | default(globals.name + ' &#8211; ' + globals.motto) | safe }}</title>
+    {% if title -%}
+    <title>{{ title + ' | ' + globals.name }}</title>
+    {%- else -%}
+    <title>{{ globals.name + ' – ' + globals.motto }}</title>
+    {%- endif %}
 
     {% block stylesheets %}
     <!-- Stylesheets -->
@@ -18,36 +26,36 @@
 
     {% block opengraph %}
     <!-- Open Graph tags -->
-    <meta property="og:url" content="{{ globals.root }}" />
+    <meta property="og:url" content="{{ root }}" />
     <meta property="og:type" content="website" />
-    <meta property="og:title" content="{{ globals.name }} &#8211; {{ globals.motto }}" />
+    <meta property="og:title" content="{{ globals.name }}  {{ globals.motto }}" />
     <meta property="og:description" content="{{ globals.description }}" />
-    <meta property="og:image" content="{{ globals.root }}/icons/600px.png?v={{ globals.v2 }}" />
+    <meta property="og:image" content="{{ root }}/icons/600px.png?v={{ globals.v2 }}" />
     <meta property="og:image:width" content="600" />
     <meta property="og:image:height" content="600" />
-    <meta property="og:image" content="{{ globals.root }}/images/fb_share.png?v={{ globals.v2 }}" />
+    <meta property="og:image" content="{{ root }}/images/fb_share.png?v={{ globals.v2 }}" />
     <meta property="og:image:width" content="1200" />
     <meta property="og:image:height" content="630" />
     <meta property="og:locale" content="en_US" />
 
     <!-- Twitter Card tags -->
     <meta name="twitter:card" content="summary">
-    <meta name="twitter:title" content="{{ globals.name }} &#8211; {{ globals.motto }}">
+    <meta name="twitter:title" content="{{ globals.name }}  {{ globals.motto }}">
     <meta name="twitter:description" content="{{ globals.description }}">
-    <meta name="twitter:image" content="{{ globals.root }}/icons/600px.png?v={{ globals.v2 }}">
+    <meta name="twitter:image" content="{{ root }}/icons/600px.png?v={{ globals.v2 }}">
     {% endblock %}
 
     <!-- Icons, configs, etcetera -->
-    <link rel="icon" href="{{ globals.root }}/icons/32pxr.png?v={{ globals.v2 }}" sizes="32x32">
-    <link rel="icon" href="{{ globals.root }}/icons/96pxr.png?v={{ globals.v2 }}" sizes="96x96">
-    <link rel="apple-touch-icon" href="{{ globals.root }}/icons/120px.png?v={{ globals.v2 }}" sizes="120x120">
-    <link rel="apple-touch-icon" href="{{ globals.root }}/icons/152px.png?v={{ globals.v2 }}" sizes="152x152">
-    <link rel="apple-touch-icon" href="{{ globals.root }}/icons/167px.png?v={{ globals.v2 }}" sizes="167x167">
-    <link rel="apple-touch-icon" href="{{ globals.root }}/icons/180px.png?v={{ globals.v2 }}" sizes="180x180">
-    <link rel="manifest" href="{{ globals.root }}/icons/manifest.json?v={{ globals.v2 }}">
+    <link rel="icon" href="{{ root }}/icons/32pxr.png?v={{ globals.v2 }}" sizes="32x32">
+    <link rel="icon" href="{{ root }}/icons/96pxr.png?v={{ globals.v2 }}" sizes="96x96">
+    <link rel="apple-touch-icon" href="{{ root }}/icons/120px.png?v={{ globals.v2 }}" sizes="120x120">
+    <link rel="apple-touch-icon" href="{{ root }}/icons/152px.png?v={{ globals.v2 }}" sizes="152x152">
+    <link rel="apple-touch-icon" href="{{ root }}/icons/167px.png?v={{ globals.v2 }}" sizes="167x167">
+    <link rel="apple-touch-icon" href="{{ root }}/icons/180px.png?v={{ globals.v2 }}" sizes="180x180">
+    <link rel="manifest" href="{{ root }}/icons/manifest.json?v={{ globals.v2 }}">
     <meta name="apple-mobile-web-app-title" content="{{ globals.name }}">
     <meta name="application-name" content="{{ globals.name }}">
-    <meta name="msapplication-config" content="{{ globals.root }}/icons/browserconfig.xml?v={{ globals.v2 }}">
+    <meta name="msapplication-config" content="{{ root }}/icons/browserconfig.xml?v={{ globals.v2 }}">
     <meta name="theme-color" content="#232629">
     {% block endmeta %}{% endblock %}
   </head>

+ 32 - 21
views/album.njk

@@ -1,5 +1,10 @@
+{% set title = album.name %}
 {% extends "_layout.njk" %}
 
+{% set fileRoot = config.domain %}
+{% set generateZips = config.uploads.generateZips %}
+{% set usingCdn = config.cloudflare and config.cloudflare.purgeCache %}
+
 {% block stylesheets %}
 <!-- Stylesheets -->
 <link rel="stylesheet" href="../libs/bulma/bulma.min.css?v={{ globals.v3 }}">
@@ -20,17 +25,17 @@
 {% block opengraph %}
 <!-- Open Graph tags -->
 <meta property="og:type" content="website" />
-<meta property="og:title" content="{{ title | safe }} &#8211; {{ count }} files" />
-<meta property="og:url" content="{{ url }}" />
-<meta property="og:description" content="{{ description | safe }}" />
-<meta property="og:image" content="{{ thumb }}" />
+<meta property="og:title" content="{{ album.name | safe }} &#8211; {{ files.length }} files" />
+<meta property="og:url" content="{{ root }}/{{ album.url }}" />
+<meta property="og:description" content="{{ album.description }}" />
+<meta property="og:image" content="{{ fileRoot }}/{{ album.thumb }}" />
 <meta property="og:locale" content="en_US" />
 
 <!-- Twitter Card tags -->
 <meta name="twitter:card" content="summary">
-<meta name="twitter:title" content="{{ title | safe }} &#8211; {{ count }} files">
-<meta name="twitter:description" content="{{ description | safe }}">
-<meta name="twitter:image" content="{{ thumb }}">
+<meta name="twitter:title" content="{{ album.name | safe }} &#8211; {{ files.length }} files">
+<meta name="twitter:description" content="{{ album.description }}">
+<meta name="twitter:image" content="{{ fileRoot }}/{{ album.thumb }}">
 {% endblock %}
 
 {% block content %}
@@ -41,21 +46,27 @@
       <div class="level-left">
         <div class="level-item">
           <h1 id="title" class="title">
-            {{ title | safe }}
+            {{ album.name | safe }}
           </h1>
         </div>
         <div class="level-item">
           <h1 id="count" class="subtitle">
-            {{ count }} files (<span class="file-size">{{ totalSize }} B</span>)
+            {{ files.length }} files (<span class="file-size">{{ album.totalSize }} B</span>)
           </h1>
         </div>
       </div>
 
-      {% if generateZips and files.length -%}
+      {% if generateZips -%}
       <div class="level-right">
         <p class="level-item">
-          {% if downloadLink -%}
-          <a class="button is-primary is-outlined" title="Be aware that album archive may be cached by CDN" href="{{ downloadLink }}">Download album</a>
+          {% if not files.length -%}
+          <a class="button is-primary is-outlined" title="There are no files in the album" disabled>Download album</a>
+          {%- elif album.downloadLink -%}
+            {%- if usingCDN -%}
+            <a class="button is-primary is-outlined" title="Be aware that album archive may be cached by CDN" href="../{{ album.downloadLink }}">Download album</a>
+            {%- else -%}
+            <a class="button is-primary is-outlined" href="../{{ album.downloadLink }}">Download album</a>
+            {%- endif -%}
           {%- else -%}
           <a class="button is-primary is-outlined" title="The album's owner has chosen to disable download" disabled>Download disabled</a>
           {%- endif %}
@@ -64,9 +75,9 @@
       {%- endif %}
     </nav>
 
-    {% if description -%}
+    {% if album.description -%}
     <h2 class="subtitle description">
-      {{ description | safe }}
+      {{ album.description | safe }}
     </h2>
     {%- endif %}
     <hr>
@@ -75,7 +86,7 @@
     <article class="message">
       <div class="message-body">
         <p>You are viewing No-JS version of this album, so file size will be displayed in bytes.</p>
-        <p>Please <a href="{{ url }}">click here</a> if you want to view its regular version.</p>
+        <p>Please <a href="../{{ album.url }}">click here</a> if you want to view its regular version.</p>
       </div>
     </article>
     {%- endif %}
@@ -83,20 +94,20 @@
     {% if files.length -%}
     <div id="table" class="columns is-multiline is-mobile is-centered has-text-centered">
       {% for file in files %}
-        <div class="image-container column is-narrow is-relative">
-          <a class="image" href="{{ file.file }}" target="_blank" rel="noopener">
+        <div class="image-container column">
+          <a class="image" href="{{ fileRoot }}/{{ file.name }}" target="_blank" rel="noopener">
             {% if file.thumb -%}
               {% if nojs -%}
-              <img alt="{{ file.name }}" src="{{ file.thumb }}" width="200" height="200" loading="lazy">
+              <img alt="{{ file.name }}" src="{{ fileRoot }}/{{ file.thumb }}" width="200" height="200" loading="lazy">
               {%- else -%}
-              <img alt="{{ file.name }}" data-src="{{ file.thumb }}">
+              <img alt="{{ file.name }}" data-src="{{ fileRoot }}/{{ file.thumb }}">
               {%- endif %}
             {%- else -%}
             <h1 class="title">{{ file.extname | default('N/A') }}</h1>
             {%- endif %}
           </a>
           <div class="details">
-            <p><span class="name" title="{{ file.file }}">{{ file.name }}</span></p>
+            <p><span class="name">{{ file.name }}</span></p>
             <p class="file-size">{{ file.size }} B</p>
           </div>
         </div>
@@ -119,7 +130,7 @@
     <div class="hero-body">
       <div class="container has-text-centered">
         <p>You have JavaScript disabled, but this page requires JavaScript to function.</p>
-        <p>Please <a href="{{ url }}?nojs">click here</a> if you want to view its No-JS version.</p>
+        <p>Please <a href="../{{ album.url }}?nojs">click here</a> if you want to view its No-JS version.</p>
       </div>
     </div>
   </section>

+ 3 - 2
views/auth.njk

@@ -1,3 +1,4 @@
+{% set title = "Auth" %}
 {% extends "_layout.njk" %}
 
 {% block stylesheets %}
@@ -30,12 +31,12 @@
           <form id="authForm">
             <div class="field">
               <div class="control">
-                <input id="user" name="user" class="input" type="text" placeholder="Your username">
+                <input id="user" name="user" class="input" type="text" placeholder="Your username" minlength="4" maxlength="32">
               </div>
             </div>
             <div class="field">
               <div class="control">
-                <input id="pass" name="pass" class="input" type="password" placeholder="Your password">
+                <input id="pass" name="pass" class="input" type="password" placeholder="Your password" minlength="6" maxlength="64">
               </div>
             </div>
             <div class="field is-grouped is-grouped-right">

+ 1 - 0
views/dashboard.njk

@@ -1,3 +1,4 @@
+{% set title = "Dashboard" %}
 {% extends "_layout.njk" %}
 
 {% block stylesheets %}

+ 7 - 1
views/faq.njk

@@ -1,5 +1,11 @@
+{% set title = "FAQ" %}
 {% extends "_layout.njk" %}
 
+{% set noJsMaxSize = config.cloudflare.noJsMaxSize | int %}
+{% set chunkSize = config.uploads.chunkSize | int %}
+{% set extensionsFilterMode = config.extensionsFilterMode %}
+{% set extensionsFilter = config.extensionsFilter %}
+
 {% macro extensions(obj) %}
 {% set space = joiner(' ') %}
 {% for id, val in obj -%}
@@ -114,7 +120,7 @@
     <article class="message">
       <div class="message-body">
         {% if extensionsFilter.length -%}
-          {%- if whitelist -%}
+          {%- if extensionsFilterMode === 'whitelist' -%}
           We only support the following extensions:
           {%- else -%}
           We support any file extensions, except the following:

+ 25 - 6
views/home.njk

@@ -1,5 +1,12 @@
 {% extends "_layout.njk" %}
 
+{% set maxSizeInt = config.maxSize | int %}
+{% set urlMaxSizeInt = config.urlMaxSize | int %}
+{% set urlDisclaimerMessage = config.uploads.urlDisclaimerMessage %}
+{% set urlExtensionsFilterMode = config.uploads.urlExtensionsFilterMode %}
+{% set urlExtensionsFilter = config.uploads.urlExtensionsFilter %}
+{% set temporaryUploadAges = config.uploads.temporaryUploadAges %}
+
 {% block endmeta %}
 {{ super() }}
 {% if globals.google_site_verification %}
@@ -39,7 +46,7 @@
       <h2 class="subtitle">{{ globals.home_subtitle | safe }}</h2>
 
       <h3 id="maxSize" class="subtitle">
-        Maximum upload size per file is <span>{{ maxSize }} MB</span>
+        Maximum upload size per file is <span>{{ maxSizeInt }} MB</span>
       </h3>
 
       <div class="columns is-gapless">
@@ -95,8 +102,8 @@
                 <textarea id="urls" class="textarea" rows="2"></textarea>
               </div>
               <p class="help">
-                {% if urlMaxSize !== maxSize -%}
-                Maximum file size per URL is <span id="urlMaxSize">{{ urlMaxSize }} MB</span>.
+                {% if urlMaxSizeInt !== maxSizeInt -%}
+                Maximum file size per URL is <span id="urlMaxSize">{{ urlMaxSizeInt }} MB</span>.
                 {%- endif %}
 
                 {% if urlExtensionsFilter.length and (urlExtensionsFilterMode === 'blacklist') -%}
@@ -144,7 +151,7 @@
                 </div>
                 <p class="help"></p>
               </div>
-              {%- if temporaryUploadAges %}
+              {%- if temporaryUploadAges and temporaryUploadAges.length %}
               <div id="uploadAgeDiv" class="field is-hidden">
                 <label class="label">Upload age</label>
                 <div class="control">
@@ -179,7 +186,19 @@
                     </select>
                   </div>
                 </div>
-                <p class="help">Newer files on top only works in <a href="https://caniuse.com/#feat=mdn-css_properties_flex-direction" target="_blank" rel="noopener">these browsers</a>.</p>
+                <p class="help">Newer files on top will use <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction#Accessibility_Concerns" target="_blank" rel="noopener">a CSS technique</a>. Trying to select their texts manually from top to bottom will end up selecting the texts from bottom to top instead.</p>
+              </div>
+              <div class="field">
+                <label class="label">Load images for preview</label>
+                <div class="control">
+                  <div class="select is-fullwidth">
+                    <select id="previewImages">
+                      <option value="default">Yes (default)</option>
+                      <option value="0">No</option>
+                    </select>
+                  </div>
+                </div>
+                <p class="help">By default, uploaded images will be loaded as their previews.</p>
               </div>
               <div class="field">
                 <p class="control">
@@ -205,7 +224,7 @@
         <div class="field">
           <i class="icon is-hidden"></i>
           <img class="is-unselectable is-hidden">
-          <p class="name is-unselectable"></p>
+          <p class="name"></p>
           <progress class="progress is-small is-danger" max="100" value="0"></progress>
           <p class="error"></p>
           <p class="link">

+ 15 - 3
views/nojs.njk

@@ -1,5 +1,17 @@
+{% set title = "No-JS uploader" %}
 {% extends "_layout.njk" %}
 
+{% set private = config.private %}
+{% set disabledMessage -%}
+  {%- if config.enableUserAccounts -%}
+  Anonymous upload is disabled.
+  {%- else -%}
+  Running in private mode.
+  {%- endif %}
+{%- endset %}
+{% set maxSizeInt = config.maxSize | int %}
+{% set noJsMaxSizeInt = config.cloudflare.noJsMaxSize | int %}
+
 {% block stylesheets %}
 {{ super() }}
 <link rel="stylesheet" href="css/home.css?v={{ globals.v1 }}">
@@ -17,15 +29,15 @@
       <h2 class="subtitle">{{ globals.home_subtitle | safe }}</h2>
 
       <h3 class="subtitle" id="maxSize">
-        Maximum total size per upload attempt is {{ renderOptions.maxFileSize }} MB
+        Maximum total size per upload attempt is {{ noJsMaxSizeInt or maxSizeInt }} MB
       </h3>
 
       <div class="columns is-gapless">
         <div class="column is-hidden-mobile"></div>
         <div class="column">
-          {% if renderOptions.uploadDisabled -%}
+          {% if config.private -%}
           <a class="button is-danger is-flex" href="auth">
-            {{ renderOptions.uploadDisabled }}
+            {{ disabledMessage }}
           </a>
           {%- else -%}
           <form id="form" class="field" action="" method="post" enctype="multipart/form-data">

+ 26 - 26
yarn.lock

@@ -915,9 +915,9 @@ [email protected]^3.0.0:
     lodash.uniq "^4.5.0"
 
 [email protected]^1.0.30000977:
-  version "1.0.30000994"
-  resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000994.tgz#891d94864a8b4a49cae58a9b4a93c5b538667794"
-  integrity sha512-7KjfAAhO0qJOs92z8lMWkcRA2ig7Ewv5SQSAy+dik8MFQCDSua+j4RbPFnGrXuOSFe/3RhmGr+68DxKZrbJQGg==
+  version "1.0.30000995"
+  resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000995.tgz#8e0557e822dab88fbbb21358d3ed395cb8efc21d"
+  integrity sha512-25ew/vPIVU0g/OjeZay2IfcljWAmNVy1TSmeoozFrJzEOqnka0ZSusJFS+4iGZKVIJ4RHOZB4NyilpwNcsh8tA==
 
 [email protected]^1.0.0, [email protected]^1.0.30000980, [email protected]^1.0.30000981, [email protected]^1.0.30000989:
   version "1.0.30000989"
@@ -1808,9 +1808,9 @@ [email protected]:
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
 [email protected]^1.3.247:
-  version "1.3.258"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.258.tgz#829b03be37424099b91aefb6815e8801bf30b509"
-  integrity sha512-rkPYrgFU7k/8ngjHYvzOZ44OQQ1GeIRIQnhGv00RkSlQXEnJKsGonQppbEEWHuuxZegpMao+WZmYraWQJQJMMg==
+  version "1.3.260"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.260.tgz#ffd686b4810bab0e1a428e7af5f08c21fe7c1fa2"
+  integrity sha512-wGt+OivF1C1MPwaSv3LJ96ebNbLAWlx3HndivDDWqwIVSQxmhL17Y/YmwUdEMtS/bPyommELt47Dct0/VZNQBQ==
 
 [email protected]^7.0.1:
   version "7.0.3"
@@ -2518,11 +2518,11 @@ [email protected]^1.0.0:
   integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
 
 [email protected]^1.2.5:
-  version "1.2.6"
-  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07"
-  integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7"
+  integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==
   dependencies:
-    minipass "^2.2.1"
+    minipass "^2.6.0"
 
 [email protected]^1.0.0:
   version "1.0.0"
@@ -4218,10 +4218,10 @@ [email protected]^1.2.0:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
   integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
 
[email protected]^2.2.1, [email protected]^2.3.5:
-  version "2.5.1"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.5.1.tgz#cf435a9bf9408796ca3a3525a8b851464279c9b8"
-  integrity sha512-dmpSnLJtNQioZFI5HfQ55Ad0DzzsMAb+HfokwRTNXwEQjepbTkl5mtIlSVxGIkOkxlpX7wIn5ET/oAd9fZ/Y/Q==
[email protected]^2.2.1, [email protected]^2.3.5, [email protected]^2.6.0:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.6.2.tgz#c3075a22680b3b1479bae5915904cb1eba50f5c0"
+  integrity sha512-38Jwdc8AttUDaQAIRX8Iaw3QoCDWjAwKMGeGDF9JUi9QCPMjH5qAQg/hdO8o1nC7Nmh1/CqzMg5FQPEKuKwznQ==
   dependencies:
     safe-buffer "^5.1.2"
     yallist "^3.0.0"
@@ -4398,9 +4398,9 @@ [email protected]^0.11.0:
     tar "^4"
 
 [email protected]^1.1.29:
-  version "1.1.30"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.30.tgz#35eebf129c63baeb6d8ddeda3c35b05abfd37f7f"
-  integrity sha512-BHcr1g6NeUH12IL+X3Flvs4IOnl1TL0JczUhEZjDE+FXXPQcVCNr8NEPb01zqGxzhTpdyJL5GXemaCW7aw6Khw==
+  version "1.1.32"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.32.tgz#485b35c1bf9b4d8baa105d782f8ca731e518276e"
+  integrity sha512-VhVknkitq8dqtWoluagsGPn3dxTvN9fwgR59fV3D7sLBHe0JfDramsMI8n8mY//ccq/Kkrf8ZRHRpsyVZ3qw1A==
   dependencies:
     semver "^5.3.0"
 
@@ -6681,17 +6681,17 @@ [email protected]^4.0.0:
     postcss "^7.0.0"
     postcss-selector-parser "^3.0.0"
 
[email protected]^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-2.2.0.tgz#46ab139db4a0e7151fd5f94af155512886c96d3f"
-  integrity sha512-bZ+d4RiNEfmoR74KZtCKmsABdBJr4iXRiCso+6LtMJPw5rd/KnxUWTxht7TbafrTJK1YRjNgnN0iVZaJfc3xJA==
[email protected]^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-3.0.0.tgz#e0e547434016c5539fe2650afd58049a2fd1d657"
+  integrity sha512-F6yTRuc06xr1h5Qw/ykb2LuFynJ2IxkKfCMf+1xqPffkxh0S09Zc902XCffcsw/XMFq/OzQ1w54fLIDtmRNHnQ==
 
[email protected]^18.3.0:
-  version "18.3.0"
-  resolved "https://registry.yarnpkg.com/stylelint-config-standard/-/stylelint-config-standard-18.3.0.tgz#a2a1b788d2cf876c013feaff8ae276117a1befa7"
-  integrity sha512-Tdc/TFeddjjy64LvjPau9SsfVRexmTFqUhnMBrzz07J4p2dVQtmpncRF/o8yZn8ugA3Ut43E6o1GtjX80TFytw==
[email protected]^19.0.0:
+  version "19.0.0"
+  resolved "https://registry.yarnpkg.com/stylelint-config-standard/-/stylelint-config-standard-19.0.0.tgz#66f0cf13f33b8a9e34965881493b38fc1313693a"
+  integrity sha512-VvcODsL1PryzpYteWZo2YaA5vU/pWfjqBpOvmeA8iB2MteZ/ZhI1O4hnrWMidsS4vmEJpKtjdhLdfGJmmZm6Cg==
   dependencies:
-    stylelint-config-recommended "^2.2.0"
+    stylelint-config-recommended "^3.0.0"
 
 [email protected]^10.1.0:
   version "10.1.0"