/* global swal, axios, ClipboardJS, LazyLoad */ const lsKeys = { token: 'token', viewType: { uploads: 'viewTypeUploads', uploadsAll: 'viewTypeUploadsAll' }, selected: { uploads: 'selectedUploads', uploadsAll: 'selectedUploadsAll', albums: 'selectedAlbums', albumsAll: 'selectedAlbumsAll', users: 'selectedUsers' }, originalNames: { uploads: 'originalNames', uploadsAll: 'originalNamesAll' } } const page = { // #dashboard section: null, // #page dom: null, // user token token: localStorage[lsKeys.token], // from api/tokens/verify username: null, permissions: null, // sidebar menus menusContainer: null, menus: [], currentView: null, views: { // params of uploads view uploads: { type: localStorage[lsKeys.viewType.uploads], originalNames: localStorage[lsKeys.originalNames.uploads] === '1', album: null, // album's id pageNum: null }, // params of uploads view (all) uploadsAll: { type: localStorage[lsKeys.viewType.uploadsAll], originalNames: localStorage[lsKeys.originalNames.uploadsAll] === '1', filters: null, pageNum: null, all: true }, // params of albums view albums: { filters: null, pageNum: null }, // params of albums view (all) albumsAll: { filters: null, pageNum: null, all: true }, // params of users view users: { filters: null, pageNum: null } }, // ids of selected items (shared across pages and will be synced with localStorage) selected: { uploads: [], uploadsAll: [], albums: [], albumsAll: [], users: [] }, checkboxes: [], lastSelected: [], // select album dom for dialogs/modals selectAlbumContainer: null, // cache for dialogs/modals cache: {}, clipboardJS: null, lazyLoad: null, imageExts: ['.webp', '.jpg', '.jpeg', '.gif', '.png', '.tiff', '.tif', '.svg'], // TODO: Disable "Load original" button with non-streamable extensions videoExts: ['.webm', '.mp4', '.wmv', '.avi', '.mov', '.mkv', '.m4v', '.m2ts'], isSomethingLoading: false, fadingIn: null, albumTitleMaxLength: 70, albumDescMaxLength: 4000 } page.unhide = () => { document.querySelector('#loader').classList.add('is-hidden') page.section.classList.remove('is-hidden') } // Handler for regular JS errors page.onError = error => { console.error(error) const content = document.createElement('div') content.innerHTML = `${error.toString()}` return swal({ title: 'An error occurred!', icon: 'error', content }) } // Handler for Axios errors page.onAxiosError = error => { console.error(error) // Better Cloudflare errors const cloudflareErrors = { 520: 'Unknown Error', 521: 'Web Server Is Down', 522: 'Connection Timed Out', 523: 'Origin Is Unreachable', 524: 'A Timeout Occurred', 525: 'SSL Handshake Failed', 526: 'Invalid SSL Certificate', 527: 'Railgun Error', 530: 'Origin DNS Error' } const statusText = cloudflareErrors[error.response.status] || error.response.statusText const description = error.response.data && error.response.data.description ? error.response.data.description : 'There was an error with the request, please check the console for more information.' return swal(`${error.response.status} ${statusText}`, description, 'error') } page.preparePage = () => { if (page.token) page.verifyToken(page.token, true) else window.location = 'auth' } page.checkClientVersion = apiVersion => { const self = document.querySelector('#mainScript') const match = self.src.match(/\?_=(\d+)$/) if (match && match[1] && match[1] !== apiVersion) return swal({ title: 'Updated detected!', text: 'Client assets have been updated. Reload to display the latest version?', icon: 'info', buttons: { confirm: { text: 'Reload', closeModal: false } } }).then(() => { window.location.reload() }) } page.verifyToken = (token, reloadOnError) => { axios.post('api/tokens/verify', { token }).then(response => { if (response.data.success === false) return swal({ title: 'An error occurred!', text: response.data.description, icon: 'error' }).then(() => { if (!reloadOnError) return localStorage.removeItem(lsKeys.token) window.location = 'auth' }) axios.defaults.headers.common.token = token localStorage[lsKeys.token] = token if (response.data.version) page.checkClientVersion(response.data.version) page.token = token page.username = response.data.username page.permissions = response.data.permissions page.prepareDashboard() }).catch(page.onAxiosError) } page.prepareDashboard = () => { page.section = document.querySelector('#dashboard') page.dom = page.section.querySelector('#page') // Capture all click events page.dom.addEventListener('click', page.domClick, true) // Capture all submit events page.dom.addEventListener('submit', event => { // Prevent default if necessary if (event.target && event.target.classList.contains('prevent-default')) return event.preventDefault() }, true) page.menusContainer = document.querySelector('#menu') // All item menus in the sidebar const itemMenus = [ { selector: '#itemUploads', onclick: page.getUploads }, { selector: '#itemDeleteUploadsByNames', onclick: page.deleteUploadsByNames }, { selector: '#itemManageYourAlbums', onclick: page.getAlbums }, { selector: '#itemManageToken', onclick: page.changeToken }, { selector: '#itemChangePassword', onclick: page.changePassword }, { selector: '#itemLogout', onclick: page.logout }, { selector: '#itemManageUploads', onclick: page.getUploads, params: { all: true }, group: 'moderator' }, { selector: '#itemManageAlbums', onclick: page.getAlbums, params: { all: true }, group: 'moderator' }, { selector: '#itemStatistics', onclick: page.getStatistics, group: 'admin' }, { selector: '#itemManageUsers', onclick: page.getUsers, group: 'admin' } ] for (let i = 0; i < itemMenus.length; i++) { // Skip item menu if not enough permission if (itemMenus[i].group && !page.permissions[itemMenus[i].group]) continue // Add onclick event listener const item = document.querySelector(itemMenus[i].selector) item.addEventListener('click', event => { if (page.isSomethingLoading) return page.warnSomethingLoading() // eslint-disable-next-line compat/compat itemMenus[i].onclick.call(null, Object.assign(itemMenus[i].params || {}, { trigger: event.currentTarget })) }) item.classList.remove('is-hidden') page.menus.push(item) } // If at least a moderator, show administration section if (page.permissions.moderator) { document.querySelector('#itemLabelAdmin').classList.remove('is-hidden') document.querySelector('#itemListAdmin').classList.remove('is-hidden') } // Update text of logout button document.querySelector('#itemLogout').innerHTML = `Logout ( ${page.username} )` // Finally display dashboard page.unhide() // Load albums sidebar page.getAlbumsSidebar() if (typeof page.prepareShareX === 'function') page.prepareShareX() } page.logout = params => { page.updateTrigger(params.trigger, 'active') localStorage.removeItem(lsKeys.token) window.location = 'auth' } page.warnSomethingLoading = () => { swal('Please wait!', 'Something else is still loading\u2026', 'warning', { buttons: false, timer: 3000 }) } page.updateTrigger = (trigger, newState) => { if (!trigger) return // Disable menus container and pagination when loading if (newState === 'loading') { page.isSomethingLoading = true page.section.classList.add('is-loading') } else { page.section.classList.remove('is-loading') page.isSomethingLoading = false } if (newState === 'loading') { trigger.classList.add('is-loading') } else if (newState === 'active') { if (trigger.parentNode.tagName !== 'LI') return for (let i = 0; i < page.menus.length; i++) page.menus[i].classList.remove('is-active') trigger.classList.remove('is-loading') trigger.classList.add('is-active') } else { trigger.classList.remove('is-loading') trigger.classList.remove('is-active') } } page.getItemID = element => { // This expects the item's parent to have the item's ID let parent = element.parentNode // If the element is part of a set of controls, use the container's parent instead if (element.parentNode.classList.contains('controls')) parent = parent.parentNode return parseInt(parent.dataset.id) } page.domClick = event => { // We are processing clicks this way to avoid using "onclick" attribute // Apparently we will need to use "unsafe-inline" for "script-src" directive // of Content Security Policy (CSP), if we want to use "onclick" attribute // Though I think that only applies to some browsers (?) // Of course it wouldn't have mattered if we didn't use CSP to begin with // Anyway, I personally would rather not use "onclick" attribute let element = event.target if (!element) return // Delegate click events to their A or BUTTON parents if (['I'].includes(element.tagName) && ['SPAN'].includes(element.parentNode.tagName)) element = element.parentNode if (['SPAN'].includes(element.tagName) && ['A', 'BUTTON'].includes(element.parentNode.tagName)) element = element.parentNode // Skip elements that have no action data if (!element.dataset || !element.dataset.action) return // Skip disabled elements if (element.hasAttribute('disabled')) return event.stopPropagation() // maybe necessary const id = page.getItemID(element) const action = element.dataset.action switch (action) { // Uploads case 'view-list': return page.setUploadsView('list', element) case 'view-thumbs': return page.setUploadsView('thumbs', element) case 'toggle-original-names': return page.toggleOriginalNames(element) case 'add-to-album': return page.addToAlbum(id) case 'delete-upload': return page.deleteUpload(id) case 'add-selected-uploads-to-album': return page.addSelectedUploadsToAlbum() case 'bulk-delete-uploads': return page.bulkDeleteUploads() case 'display-preview': return page.displayPreview(id) // Manage uploads case 'upload-filters-help': return page.uploadFiltersHelp(element) case 'filter-uploads': return page.filterUploads(element) // Manage your albums case 'submit-album': return page.submitAlbum(element) case 'edit-album': return page.editAlbum(id) case 'disable-album': return page.disableAlbum(id) case 'view-album-uploads': return page.viewAlbumUploads(id, element) // Manage users case 'create-user': return page.createUser() case 'edit-user': return page.editUser(id) case 'disable-user': return page.disableUser(id) case 'delete-user': return page.deleteUser(id) case 'view-user-uploads': return page.viewUserUploads(id, element) // Others case 'get-new-token': return page.getNewToken(element) // Uploads & Users case 'clear-selection': return page.clearSelection() case 'select': return page.select(element, event) case 'select-all': return page.selectAll(element) case 'page-ellipsis': return page.focusJumpToPage(element) case 'page-prev': case 'page-next': case 'page-goto': case 'jump-to-page': return page.switchPage(action, element) } } page.fadeAndScroll = disableFading => { if (page.fadingIn) { clearTimeout(page.fadingIn) page.dom.classList.remove('fade-in') } if (!disableFading) { page.dom.classList.add('fade-in') page.fadingIn = setTimeout(() => { page.dom.classList.remove('fade-in') }, 500) } page.dom.scrollIntoView({ behavior: disableFading ? 'auto' : 'smooth', block: 'start', inline: 'nearest' }) } page.getByView = (view, get) => { switch (view) { case 'uploads': case 'uploadsAll': return { type: 'uploads', func: page.getUploads }[get] case 'albums': case 'albumsAll': return { type: 'albums', func: page.getAlbums }[get] case 'users': return { type: 'users', func: page.getUsers }[get] default: return null } } page.switchPage = (action, element) => { if (page.isSomethingLoading) return page.warnSomethingLoading() // eslint-disable-next-line compat/compat const params = Object.assign(page.views[page.currentView], { trigger: element }) const func = page.getByView(page.currentView, 'func') switch (action) { case 'page-prev': params.pageNum = page.views[page.currentView].pageNum - 1 if (params.pageNum < 0) return swal('An error occurred!', 'This is already the first page.', 'error') return func(params) case 'page-next': params.pageNum = page.views[page.currentView].pageNum + 1 return func(params) case 'page-goto': params.pageNum = parseInt(element.dataset.goto) return func(params) case 'jump-to-page': { const jumpToPage = document.querySelector(`#${element.dataset.jumpid || 'jumpToPage'}`) if (!jumpToPage.checkValidity()) return const parsed = parseInt(jumpToPage.value) params.pageNum = isNaN(parsed) ? 0 : (parsed - 1) if (params.pageNum < 0) params.pageNum = 0 return func(params) } } } page.focusJumpToPage = element => { const jumpToPage = document.querySelector(`#${element.dataset.jumpid || 'jumpToPage'}`) if (!jumpToPage) return jumpToPage.focus() jumpToPage.select() } page.getUploads = (params = {}) => { if (params && params.all && !page.permissions.moderator) return swal('An error occurred!', 'You cannot do this!', 'error') if (page.isSomethingLoading) return page.warnSomethingLoading() page.updateTrigger(params.trigger, 'loading') if (typeof params.pageNum !== 'number' || params.pageNum < 0) params.pageNum = 0 const url = params.album !== undefined ? `api/album/${params.album}/${params.pageNum}` : `api/uploads/${params.pageNum}` const headers = {} if (params.all) headers.all = '1' if (params.filters) { headers.filters = params.filters // Send client timezone offset if properly using date: and/or :expiry filters // Server will pretend client is on UTC if unset if (/(^|\s)(date|expiry):[\d"]/.test(params.filters)) headers.minoffset = new Date().getTimezoneOffset() } axios.get(url, { headers }).then(response => { if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { page.updateTrigger(params.trigger) return swal('An error occurred!', response.data.description, 'error') } const pages = Math.ceil(response.data.count / 25) const files = response.data.files if (params.pageNum && (files.length === 0)) { page.updateTrigger(params.trigger) if (params.autoPage) { params.pageNum = pages - 1 return page.getUploads(params) } else { return swal('An error occurred!', `There are no more uploads to populate page ${params.pageNum + 1}.`, 'error') } } page.currentView = params.all ? 'uploadsAll' : 'uploads' page.cache = {} const albums = response.data.albums const users = response.data.users const basedomain = response.data.basedomain if (params.pageNum < 0) params.pageNum = Math.max(0, pages + params.pageNum) const pagination = page.paginate(response.data.count, 25, params.pageNum) const filter = `
` const extraControls = `
${filter}
` const controls = `
Toggle original names
${params.all ? '' : ` `} Bulk delete
` // Do some string replacements for bottom controls const bottomFiltersId = 'bFilters' const bottomJumpId = 'bJumpToPage' const bottomExtraControls = extraControls .replace(/id="filters"/, `id="${bottomFiltersId}"`) .replace(/(data-action="filter-uploads")/, `$1 data-filtersid="${bottomFiltersId}"`) .replace(/id="jumpToPage"/, `id="${bottomJumpId}"`) .replace(/(data-action="jump-to-page")/g, `$1 data-jumpid="${bottomJumpId}"`) const bottomPagination = pagination .replace(/(data-action="page-ellipsis")/g, `$1 data-jumpid="${bottomJumpId}"`) // Whether there are any unselected items let unselected = false const showOriginalNames = page.views[page.currentView].originalNames const hasExpiryDateColumn = files.some(file => { return file.expirydate !== undefined }) for (let i = 0; i < files.length; i++) { // Build full URLs files[i].file = `${basedomain}/${files[i].name}` 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[files[i].id] = { name: files[i].name, original: files[i].original, thumb: files[i].thumb, file: files[i].file, type: files[i].type } // Prettify files[i].prettyBytes = page.getPrettyBytes(parseInt(files[i].size)) files[i].prettyDate = page.getPrettyDate(new Date(files[i].timestamp * 1000)) if (hasExpiryDateColumn) files[i].prettyExpiryDate = files[i].expirydate ? page.getPrettyDate(new Date(files[i].expirydate * 1000)) : null // Update selected status files[i].selected = page.selected[page.currentView].includes(files[i].id) if (!files[i].selected) unselected = true // Appendix (display album or user) if (params.all) files[i].appendix = files[i].userid ? users[files[i].userid] || '' : '' else if (params.album === undefined) files[i].appendix = files[i].albumid ? albums[files[i].albumid] || '' : '' } if (page.views[page.currentView].type === 'thumbs') { page.dom.innerHTML = ` ${pagination} ${extraControls} ${controls}
${controls} ${bottomExtraControls} ${bottomPagination} ` const table = document.querySelector('#table') for (let i = 0; i < files.length; i++) { const upload = files[i] const div = document.createElement('div') div.className = 'image-container column' div.dataset.id = upload.id if (upload.thumb !== undefined) div.innerHTML = `${upload.name}` else div.innerHTML = `

${upload.extname || 'N/A'}

` div.innerHTML += `
${upload.thumb ? ` ` : ''}

${upload.name}

${showOriginalNames ? `

${upload.original}

` : ''}

${upload.appendix ? `${upload.appendix} – ` : ''}${upload.prettyBytes}

${hasExpiryDateColumn && upload.prettyExpiryDate ? `

EXP: ${upload.prettyExpiryDate}

` : ''}
` table.appendChild(div) page.checkboxes = table.querySelectorAll('.checkbox[data-action="select"]') } } else { const allAlbums = params.all && params.filters && params.filters.includes('albumid:') page.dom.innerHTML = ` ${pagination} ${extraControls} ${controls}
${showOriginalNames ? '' : ''} ${params.album === undefined ? `` : ''} ${allAlbums ? '' : ''} ${params.all ? '' : ''} ${hasExpiryDateColumn ? '' : ''}
File nameOriginal nameUser' : 'albumid">Album'}AlbumSizeIPUpload dateExpiry date
${controls} ${bottomExtraControls} ${bottomPagination} ` const table = document.querySelector('#table') for (let i = 0; i < files.length; i++) { const upload = files[i] const tr = document.createElement('tr') tr.dataset.id = upload.id tr.innerHTML = ` ${upload.name} ${showOriginalNames ? `${upload.original}` : ''} ${params.album === undefined ? `${upload.appendix}` : ''} ${allAlbums ? `${upload.albumid ? (albums[upload.albumid] || '') : ''}` : ''} ${upload.prettyBytes} ${params.all ? `${upload.ip || ''}` : ''} ${upload.prettyDate} ${hasExpiryDateColumn ? `${upload.prettyExpiryDate || '-'}` : ''} ${params.all ? '' : ` `} ` table.appendChild(tr) page.checkboxes = table.querySelectorAll('.checkbox[data-action="select"]') } } const selectAll = document.querySelector('#selectAll') if (selectAll && !unselected && files.length) { selectAll.checked = true selectAll.title = 'Unselect all' } if (page.views[page.currentView].type === 'thumbs') { page.fadeAndScroll(true) page.lazyLoad.update() } else { page.fadeAndScroll() } page.updateTrigger(params.trigger, 'active') if (page.currentView === 'uploads') page.views.uploads.album = params.album page.views[page.currentView].filters = params.filters page.views[page.currentView].pageNum = files.length ? params.pageNum : 0 }).catch(error => { page.updateTrigger(params.trigger) page.onAxiosError(error) }) } page.setUploadsView = (view, element) => { if (page.isSomethingLoading) return page.warnSomethingLoading() if (view === 'list') { delete localStorage[lsKeys.viewType[page.currentView]] page.views[page.currentView].type = undefined } else { localStorage[lsKeys.viewType[page.currentView]] = view page.views[page.currentView].type = view } // eslint-disable-next-line compat/compat page.getUploads(Object.assign(page.views[page.currentView], { trigger: element })) } page.toggleOriginalNames = element => { if (page.isSomethingLoading) return page.warnSomethingLoading() if (page.views[page.currentView].originalNames) { delete localStorage[lsKeys.originalNames[page.currentView]] page.views[page.currentView].originalNames = false } else { localStorage[lsKeys.originalNames[page.currentView]] = '1' page.views[page.currentView].originalNames = true } // eslint-disable-next-line compat/compat page.getUploads(Object.assign(page.views[page.currentView], { trigger: element })) } page.displayPreview = id => { const file = page.cache[id] if (!file.thumb) return const div = document.createElement('div') div.innerHTML = `

${file.name}
${file.original}

` if (file.file) { const exec = /.[\w]+(\?|$)/.exec(file.file) const extname = exec && exec[0] ? exec[0].toLowerCase() : null const isimage = page.imageExts.includes(extname) const isvideo = !isimage && page.videoExts.includes(extname) if (isimage || isvideo) { div.innerHTML += `
Load original
` div.querySelector('#swalOriginal').addEventListener('click', event => { const trigger = event.currentTarget if (trigger.classList.contains('is-danger')) return trigger.classList.add('is-loading') const thumb = div.querySelector('#swalThumb') if (isimage) { thumb.src = file.file thumb.onload = () => { trigger.classList.add('is-hidden') document.body.querySelector('.swal-overlay .swal-modal:not(.is-expanded)').classList.add('is-expanded') } thumb.onerror = event => { event.currentTarget.classList.add('is-hidden') trigger.className = 'button is-danger is-fullwidth' trigger.innerHTML = ` Unable to load original ` } } else if (isvideo) { thumb.classList.add('is-hidden') const video = document.createElement('video') video.id = 'swalVideo' video.controls = true video.autoplay = true video.src = file.file thumb.insertAdjacentElement('afterend', video) trigger.classList.add('is-hidden') document.body.querySelector('.swal-overlay .swal-modal:not(.is-expanded)').classList.add('is-expanded') } }) } } return swal({ content: div, buttons: false }).then(() => { // Destroy video, if necessary const video = div.querySelector('#swalVideo') if (video) video.remove() // Restore modal size document.body.querySelector('.swal-overlay .swal-modal').classList.remove('is-expanded') }) } page.selectAll = element => { for (let i = 0; i < page.checkboxes.length; i++) { const id = page.getItemID(page.checkboxes[i]) if (isNaN(id)) continue if (page.checkboxes[i].checked !== element.checked) { page.checkboxes[i].checked = element.checked if (page.checkboxes[i].checked) page.selected[page.currentView].push(id) else page.selected[page.currentView].splice(page.selected[page.currentView].indexOf(id), 1) } } if (page.selected[page.currentView].length) localStorage[lsKeys.selected[page.currentView]] = JSON.stringify(page.selected[page.currentView]) else delete localStorage[lsKeys.selected[page.currentView]] element.title = element.checked ? 'Unselect all' : 'Select all' } page.selectInBetween = (element, lastElement) => { const thisIndex = parseInt(element.dataset.index) const lastIndex = parseInt(lastElement.dataset.index) const distance = Math.abs(thisIndex - lastIndex) if (distance < 2) return for (let i = 0; i < page.checkboxes.length; i++) if ((thisIndex > lastIndex && i > lastIndex && i < thisIndex) || (thisIndex < lastIndex && i > thisIndex && i < lastIndex)) { // Check or uncheck depending on the state of the initial checkbox const checked = page.checkboxes[i].checked = lastElement.checked const id = page.getItemID(page.checkboxes[i]) if (!page.selected[page.currentView].includes(id) && checked) page.selected[page.currentView].push(id) else if (page.selected[page.currentView].includes(id) && !checked) page.selected[page.currentView].splice(page.selected[page.currentView].indexOf(id), 1) } } page.select = (element, event) => { const id = page.getItemID(element) if (isNaN(id)) return if (event.shiftKey && page.lastSelected) { page.selectInBetween(element, page.lastSelected) // Check or uncheck depending on the state of the initial checkbox element.checked = page.lastSelected.checked } else { page.lastSelected = element } if (!page.selected[page.currentView].includes(id) && element.checked) page.selected[page.currentView].push(id) else if (page.selected[page.currentView].includes(id) && !element.checked) page.selected[page.currentView].splice(page.selected[page.currentView].indexOf(id), 1) // Update local storage if (page.selected[page.currentView].length) localStorage[lsKeys.selected[page.currentView]] = JSON.stringify(page.selected[page.currentView]) else delete localStorage[lsKeys.selected[page.currentView]] } page.clearSelection = () => { const selected = page.selected[page.currentView] const type = page.getByView(page.currentView, 'type') const count = selected.length if (!count) return swal('An error occurred!', `You have not selected any ${type}.`, 'error') const suffix = count === 1 ? type.substring(0, type.length - 1) : type return swal({ title: 'Are you sure?', text: `You are going to unselect ${count} ${suffix}.`, buttons: true }).then(proceed => { if (!proceed) return const checkboxes = page.checkboxes for (let i = 0; i < checkboxes.length; i++) if (checkboxes[i].checked) checkboxes[i].checked = false page.selected[page.currentView] = [] delete localStorage[lsKeys.selected[page.currentView]] const selectAll = document.querySelector('#selectAll') if (selectAll) selectAll.checked = false return swal('Cleared selection!', `Unselected ${count} ${suffix}.`, 'success') }) } page.uploadFiltersHelp = element => { const all = Boolean(element.dataset.all) const content = document.createElement('div') content.style = 'text-align: left' content.innerHTML = `${all ? ` There are 2 filter keys, namely user (username) and ip. These keys can be specified more than once. For usernames with whitespaces, wrap them with double quotes ("). Special cases such as uploads by non-registered users or have no IPs respectively, use user:- or ip:-. To exclude certain users/ips while still listing every other uploads, add negation sign (-) before the keys. Negation sign can also be used to exclude the special cases mentioned above (i.e. -user:- or -ip:-). If you know the ID of a user's album, you can list its uploads with albumid key. Negation sign works for this key as well. ` : ` There is only 1 filter key, namely albumid. This key can be specified more than once. Special case such as uploads with no albums, use albumid:-. To exclude certain albums while still listing every other uploads, add negation sign (-) before the keys. Negation sign can also be used to exclude the special case mentioned above (i.e. -albumid:-). `} There are 2 range keys: date (upload date) and expiry (expiry date). Their format is: "YYYY/MM/DD HH:MM:SS-YYYY/MM/DD HH:MM:SS" ("from" date and "to" date respectively). You may specify only one of the dates. If "to" date is missing, 'now' will be used. If "from" date is missing, 'beginning of time' will be used. If any of the subsequent date or time units are not specified, their first value will be used (e.g. January for month, 1 for day, and so on). If only time is specified, today's date will be used. If you do not need to specify both date and time, you may omit the double quotes. In conclusion, the following examples are all valid: date:"2020/01/01 01:23-2018/01/01 06", expiry:-2020/05, date:12:34:56. These keys can only be specified once each. Timezone? Feel free to query the dates with your own timezone. API requests to the filter endpoint will attach your browser's timezone offset, so the server will automatically calculate timezone differences. Matches can also be sorted with sort keys. Their formats are: sort:columnName[:d[escending]], where :d[escending] is an optional tag to set the direction to descending. This key must be used with internal column names used in the database (id, ${all ? 'userid' : 'albumid'}, and so on), but there are 2 shortcuts available: date for timestamp column and expiry for expirydate column. This key can also be specified more than once, where their order will decide the sorting steps. Finally, there are type-is keys to refine by types. You can use is:image and is:video to list images and videos respectively. This will only use image/video extensions whose thumbnails can be generated by the safe. Negation sign works for this key as well. Mixing inclusion and exclusion is not allowed (i.e. is:image -is:video, since the second key is redundant). Any leftover keywords which do not use keys (non-keyed keywords) will be matched against the matches' file names. Excluding certain keywords is also supported by adding negation sign before the keywords. Internal steps: ${all ? `- Query uploads passing ALL exclusion filter keys OR matching ANY filter keys, if any. - Refine matches` : '- Filter uploads'} using date key, if any. - Refine matches using expiry key, if any. - Refine matches using type-is keys, if any. - Refine matches using ANY non-keyed keywords, if any. - Filter matches using ALL exclusion non-keyed keywords, if any. - Sort matches using sorting keys, if any. Examples: ${all ? `- Uploads from users named "demo" AND/OR "John Doe" AND/OR non-registered users: user:demo user:"John Doe" user:- - ALL uploads, but NOT the ones from user named "demo" AND "John Doe": -user:demo -user:"John Doe" - Uploads from IP "127.0.0.1" AND which file names match "*.rar" OR "*.zip": ip:127.0.0.1 *.rar *.zip ` : ''}- Uploads without albums: albumid:- - ALL uploads, but NOT the ones from album with ID 69: -albumid:69 - Uploads uploaded since "1 June 2019 00:00:00": date:2019/06 - Uploads uploaded between "7 April 2020 12:00:00" and "7 April 2020 23:59:59": date:"2020/04/07 12-2020/04/07 23:59:59" - Uploads uploaded before "5 February 2020 00:00:00": date:-2020/02/05 - Uploads which file names match "*.gz" but NOT "*.tar.gz": *.gz -*.tar.gz - Sort matches by "size" column in ascending and descending order respectively: ${all ? 'user:"John Doe"' : '*.txt'} sort:size *.mp4 ${all ? 'user:- ' : ''}sort:size:d ${!page.permissions.moderator ? ` Notice: Regular users may face some limitations in the amount of keys that can be used at a time. ` : ''} Friendly reminder: This window can be scrolled up! `.trim().replace(/^\s*/g, '').replace(/\n/g, '
') swal({ content }).then(() => { // Restore modal size document.body.querySelector('.swal-overlay .swal-modal').classList.remove('is-expanded') }) // Expand modal size document.body.querySelector('.swal-overlay .swal-modal:not(.is-expanded)').classList.add('is-expanded') } page.filterUploads = element => { const filters = document.querySelector(`#${element.dataset.filtersid || 'filters'}`).value .trim() .replace(/\t/g, ' ') .replace(/(^|\s)((albumid|ip|user|date|expiry|is|sort|orderby):)\s+/g, '$2') // eslint-disable-next-line compat/compat page.getUploads(Object.assign(page.views[page.currentView], { filters, pageNum: 0, trigger: element })) } page.viewUserUploads = (id, element) => { const user = page.cache[id] if (!user) return element.classList.add('is-loading') // Wrap username in quotes if it contains whitespaces const username = user.username.includes(' ') ? `"${user.username}"` : user.username page.getUploads({ all: true, filters: `user:${username}`, trigger: document.querySelector('#itemManageUploads') }) } page.viewAlbumUploads = (id, element) => { if (!page.cache[id]) return element.classList.add('is-loading') // eslint-disable-next-line compat/compat const all = page.currentView === 'albumsAll' && page.permissions.moderator page.getUploads({ all, filters: `albumid:${id}`, trigger: all ? document.querySelector('#itemManageUploads') : document.querySelector('#itemUploads') }) } page.deleteUpload = id => { page.postBulkDeleteUploads({ all: page.currentView === 'uploadsAll', field: 'id', values: [id], cb (failed) { // Remove from remembered checkboxes if necessary if (!failed.length && page.selected[page.currentView].includes(id)) page.selected[page.currentView].splice(page.selected[page.currentView].indexOf(id), 1) // Update local storage if (page.selected[page.currentView].length) localStorage[lsKeys.selected[page.currentView]] = JSON.stringify(page.selected[page.currentView]) else delete localStorage[lsKeys.selected[page.currentView]] // Reload upload list // eslint-disable-next-line compat/compat page.getUploads(Object.assign(page.views[page.currentView], { autoPage: true })) } }) } page.bulkDeleteUploads = () => { const count = page.selected[page.currentView].length if (!count) return swal('An error occurred!', 'You have not selected any uploads.', 'error') page.postBulkDeleteUploads({ all: page.currentView === 'uploadsAll', field: 'id', values: page.selected[page.currentView], cb (failed) { // Update state of checkboxes if (failed.length) page.selected[page.currentView] = page.selected[page.currentView] .filter(id => { return failed.includes(id) }) else page.selected[page.currentView] = [] // Update local storage if (page.selected[page.currentView].length) localStorage[lsKeys.selected[page.currentView]] = JSON.stringify(page.selected[page.currentView]) else delete localStorage[lsKeys.selected[page.currentView]] // Reload uploads list // eslint-disable-next-line compat/compat page.getUploads(Object.assign(page.views[page.currentView], { autoPage: true })) } }) } page.deleteUploadsByNames = (params = {}) => { let appendix = '' if (page.permissions.moderator) appendix = '
Hint: You can use this feature to delete uploads by other users.' page.dom.innerHTML = `

Separate each entry with a new line.${appendix}

` page.fadeAndScroll() page.updateTrigger(params.trigger, 'active') document.querySelector('#submitBulkDelete').addEventListener('click', () => { if (page.isSomethingLoading) return page.warnSomethingLoading() const textArea = document.querySelector('#bulkDeleteNames') // Clean up const seen = {} const names = textArea.value .split(/\r?\n/) .map(name => { const trimmed = name.trim() return /^[^\s]+$/.test(trimmed) ? trimmed : '' }) .filter(name => { // Filter out invalid and duplicate names return (!name || Object.prototype.hasOwnProperty.call(seen, name)) ? false : (seen[name] = true) }) // Update textarea with cleaned names textArea.value = names.join('\n') if (!names.length) return swal('An error occurred!', 'You have not entered any upload names.', 'error') page.postBulkDeleteUploads({ all: true, field: 'name', values: names, cb (failed) { textArea.value = failed.join('\n') } }) }) } page.postBulkDeleteUploads = (params = {}) => { const count = params.values.length const objective = `${params.values.length} upload${count === 1 ? '' : 's'}` const boldObjective = objective.replace(/^(\d*)(.*)/, '$1$2') let text = `

You won't be able to recover ${boldObjective}!

` if (params.all) { const obj1 = count === 1 ? 'an upload' : 'some uploads' const obj2 = count === 1 ? 'another user' : 'other users' text += `\n

Warning: You may be nuking ${obj1} by ${obj2}!

` } const content = document.createElement('div') content.innerHTML = text swal({ title: 'Are you sure?', content, icon: 'warning', dangerMode: true, buttons: { cancel: true, confirm: { text: `Yes, nuke ${params.values.length === 1 ? 'it' : 'them'}!`, closeModal: false } } }).then(proceed => { if (!proceed) return axios.post('api/upload/bulkdelete', { field: params.fields, values: params.values }).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { return swal('An error occurred!', response.data.description, 'error') } const failed = Array.isArray(response.data.failed) ? response.data.failed : [] if (failed.length === params.values.length) swal('An error occurred!', `Unable to delete any of the ${objective}.`, 'error') else if (failed.length && failed.length < params.values.length) swal('Warning!', `From ${objective}, unable to delete ${failed.length} of them.`, 'warning') else swal('Deleted!', `${objective} ${count === 1 ? 'has' : 'have'} been deleted.`, 'success', { buttons: false, timer: 1500 }) if (typeof params.cb === 'function') params.cb(failed) }).catch(page.onAxiosError) }) } page.addSelectedUploadsToAlbum = () => { if (page.currentView !== 'uploads') return const count = page.selected[page.currentView].length if (!count) return swal('An error occurred!', 'You have not selected any uploads.', 'error') page.addUploadsToAlbum(page.selected[page.currentView], failed => { if (!failed) return if (failed.length) page.selected[page.currentView] = page.selected[page.currentView].filter(id => { return failed.includes(id) }) else page.selected[page.currentView] = [] localStorage[lsKeys.selected[page.currentView]] = JSON.stringify(page.selected[page.currentView]) page.getUploads(page.views[page.currentView]) }) } page.addToAlbum = id => { page.addUploadsToAlbum([id], failed => { if (!failed) return page.getUploads(page.views[page.currentView]) }) } page.addUploadsToAlbum = (ids, callback) => { const count = ids.length const content = document.createElement('div') content.innerHTML = `

You are about to add ${count} upload${count === 1 ? '' : 's'} to an album.

If an upload is already in an album, it will be moved.

` swal({ icon: 'warning', content, buttons: { cancel: true, confirm: { text: 'OK', closeModal: false } } }).then(choose => { if (!choose) return const albumid = parseInt(document.querySelector('#swalAlbum').value) if (isNaN(albumid)) return swal('An error occurred!', 'You did not choose an album.', 'error') axios.post('api/albums/addfiles', { ids, albumid }).then(add => { if (!add) return if (add.data.success === false) { if (add.data.description === 'No token provided') page.verifyToken(page.token) else swal('An error occurred!', add.data.description, 'error') return } let added = ids.length if (add.data.failed && add.data.failed.length) added -= add.data.failed.length const suffix = `upload${ids.length === 1 ? '' : 's'}` if (!added) return swal('An error occurred!', `Could not add the ${suffix} to the album.`, 'error') swal('Woohoo!', `Successfully ${albumid < 0 ? 'removed' : 'added'} ${added} ${suffix} ${albumid < 0 ? 'from' : 'to'} the album.`, 'success', { buttons: false, timer: 1500 }) callback(add.data.failed) }).catch(page.onAxiosError) }) // Get albums list then update content of swal axios.get('api/albums').then(list => { if (list.data.success === false) { if (list.data.description === 'No token provided') page.verifyToken(page.token) else swal('An error occurred!', list.data.description, 'error') return } // If the prompt was replaced, the container would be missing const select = document.querySelector('#swalAlbum') if (!select) return select.innerHTML += list.data.albums .map(album => { return `` }) .join('\n') select.getElementsByTagName('option')[1].innerHTML = 'Choose an album' select.removeAttribute('disabled') }).catch(page.onAxiosError) } page.getAlbums = (params = {}) => { if (params && params.all && !page.permissions.moderator) return swal('An error occurred!', 'You cannot do this!', 'error') if (page.isSomethingLoading) return page.warnSomethingLoading() page.updateTrigger(params.trigger, 'loading') if (typeof params.pageNum !== 'number') params.pageNum = 0 const headers = {} if (params.all) headers.all = '1' const url = `api/albums/${params.pageNum}` axios.get(url, { headers }).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { page.updateTrigger(params.trigger) return swal('An error occurred!', response.data.description, 'error') } const pages = Math.ceil(response.data.count / 25) const albums = response.data.albums if (params.pageNum && (albums.length === 0)) { page.updateTrigger(params.trigger) if (params.autoPage) { params.pageNum = pages - 1 return page.getAlbums(params) } else { return swal('An error occurred!', `There are no more albums to populate page ${params.pageNum + 1}.`, 'error') } } page.currentView = params.all ? 'albumsAll' : 'albums' page.cache = {} const users = response.data.users const homeDomain = response.data.homeDomain if (params.pageNum < 0) params.pageNum = Math.max(0, pages + params.pageNum) const pagination = page.paginate(response.data.count, 25, params.pageNum) const filter = `
` const extraControls = `
${filter}
` const controls = `
${!params.all ? 'Bulk disable' : ''} ${params.all ? ` Bulk delete ` : ''}
` // Do some string replacements for bottom controls const bottomFiltersId = 'bFilters' const bottomJumpId = 'bJumpToPage' const bottomExtraControls = extraControls .replace(/id="filters"/, `id="${bottomFiltersId}"`) .replace(/(data-action="filter-uploads")/, `$1 data-filtersid="${bottomFiltersId}"`) .replace(/id="jumpToPage"/, `id="${bottomJumpId}"`) .replace(/(data-action="jump-to-page")/g, `$1 data-jumpid="${bottomJumpId}"`) const bottomPagination = pagination .replace(/(data-action="page-ellipsis")/g, `$1 data-jumpid="${bottomJumpId}"`) // Whether there are any unselected items let unselected = false const createNewAlbum = `

Create new album

Max length is ${page.albumTitleMaxLength} characters.

Max length is ${page.albumDescMaxLength} characters.


` page.dom.innerHTML = ` ${!params.all ? createNewAlbum : ''} ${pagination} ${extraControls} ${controls}
${params.all ? '' : ''}
ID NameUserUploads Created at Public link
${controls} ${bottomExtraControls} ${bottomPagination} ` const table = document.querySelector('#table') for (let i = 0; i < albums.length; i++) { const album = albums[i] const albumUrl = `${homeDomain}/a/${album.identifier}` const selected = page.selected[page.currentView].includes(album.id) if (!selected) unselected = true // Prettify album.prettyDate = page.getPrettyDate(new Date(album.timestamp * 1000)) // Server-side explicitly expect this value to consider an album as disabled const enabled = album.enabled !== 0 page.cache[album.id] = { name: album.name, download: album.download, public: album.public, description: album.description, enabled } const tr = document.createElement('tr') tr.dataset.id = album.id tr.innerHTML = ` ${album.id} ${album.name} ${params.all ? `${album.userid ? (users[album.userid] || '') : ''}` : ''} ${album.uploads} ${album.prettyDate} ${albumUrl} ` table.appendChild(tr) page.checkboxes = table.querySelectorAll('.checkbox[data-action="select"]') } const selectAll = document.querySelector('#selectAll') if (selectAll && !unselected) { selectAll.checked = true selectAll.title = 'Unselect all' } page.fadeAndScroll() page.updateTrigger(params.trigger, 'active') if (page.currentView === 'albumsAll') page.views[page.currentView].filters = params.filters page.views[page.currentView].pageNum = albums.length ? params.pageNum : 0 }).catch(error => { page.updateTrigger(params.trigger) page.onAxiosError(error) }) } page.editAlbum = id => { const album = page.cache[id] if (!album) return const div = document.createElement('div') div.innerHTML = `

Max length is ${page.albumTitleMaxLength} characters.

Max length is ${page.albumDescMaxLength} characters.

${page.currentView === 'albumsAll' && page.permissions.moderator ? `
` : ''}
` swal({ title: 'Edit album', icon: 'info', content: div, buttons: { cancel: true, confirm: { closeModal: false } } }).then(value => { if (!value) return const post = { id, name: document.querySelector('#swalName').value.trim(), description: document.querySelector('#swalDescription').value.trim(), download: document.querySelector('#swalDownload').checked, public: document.querySelector('#swalPublic').checked, requestLink: document.querySelector('#swalRequestLink').checked } if (page.currentView === 'albumsAll' && page.permissions.moderator) post.enabled = document.querySelector('#swalEnabled').checked axios.post('api/albums/edit', post).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { return swal('An error occurred!', response.data.description, 'error') } if (response.data.identifier) swal('Success!', `The album's new identifier is: ${response.data.identifier}.`, 'success') else if (response.data.name !== album.name) swal('Success!', `The album was renamed to: ${response.data.name}.`, 'success') else swal('Success!', 'The album was edited.', 'success', { buttons: false, timer: 1500 }) page.getAlbumsSidebar() // Reload albums list // eslint-disable-next-line compat/compat page.getAlbums(Object.assign(page.views[page.currentView], { autoPage: true })) }).catch(page.onAxiosError) }) } page.disableAlbum = id => { swal({ title: 'Are you sure?', text: 'This won\'t delete the uploads associated with the album!', icon: 'warning', dangerMode: true, buttons: { cancel: true, confirm: { text: 'Yes, disable it!', closeModal: false }, purge: { text: 'Umm, delete the uploads too, please?', value: 'purge', className: 'swal-button--danger', closeModal: false } } }).then(proceed => { if (!proceed) return axios.post('api/albums/disable', { id, purge: proceed === 'purge' }).then(response => { if (response.data.success === false) { const failed = Array.isArray(response.data.failed) ? response.data.failed : [] if (response.data.description === 'No token provided') return page.verifyToken(page.token) else if (failed.length) return swal('An error occurred!', `Unable to delete ${failed.length} of the album's upload${failed.length === 1 ? '' : 's'}.`, 'error') else return swal('An error occurred!', response.data.description, 'error') } swal('Deleted!', 'Your album has been disabled.', 'success', { buttons: false, timer: 1500 }) page.getAlbumsSidebar() // Reload albums list // eslint-disable-next-line compat/compat page.getAlbums(Object.assign(page.views[page.currentView], { autoPage: true })) }).catch(page.onAxiosError) }) } page.submitAlbum = element => { page.updateTrigger(element, 'loading') axios.post('api/albums', { name: document.querySelector('#albumName').value.trim(), description: document.querySelector('#albumDescription').value.trim() }).then(response => { if (!response) return page.updateTrigger(element) if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { return swal('An error occurred!', response.data.description, 'error') } swal('Woohoo!', 'Album was created successfully.', 'success', { buttons: false, timer: 1500 }) page.getAlbumsSidebar() page.getAlbums({ pageNum: -1 }) }).catch(error => { page.updateTrigger(element) page.onAxiosError(error) }) } page.getAlbumsSidebar = () => { axios.get('api/albums', { headers: { sidebar: '1' } }).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { return swal('An error occurred!', response.data.description, 'error') } const albums = response.data.albums const count = response.data.count const albumsContainer = document.querySelector('#albumsContainer') // Clear albums sidebar if necessary const oldAlbums = albumsContainer.querySelectorAll('li > a') if (oldAlbums.length) { for (let i = 0; i < oldAlbums.length; i++) page.menus.splice(page.menus.indexOf(oldAlbums[i]), 1) albumsContainer.innerHTML = '' } if (albums === undefined) return for (let i = 0; i < albums.length; i++) { const album = albums[i] const li = document.createElement('li') const a = document.createElement('a') a.id = album.id a.className = 'is-relative' a.innerHTML = album.name a.addEventListener('click', event => { page.getUploads({ album: parseInt(event.currentTarget.id), trigger: event.currentTarget }) }) page.menus.push(a) li.appendChild(a) albumsContainer.appendChild(li) } if (count > albums.length) { const li = document.createElement('li') const a = document.createElement('a') a.className = 'is-relative' a.innerHTML = '...' a.title = `You have ${count} albums, but the sidebar can only list your first ${albums.length} albums.` a.addEventListener('click', event => { page.getAlbums({ trigger: document.querySelector('#itemManageYourAlbums') }) }) li.appendChild(a) albumsContainer.appendChild(li) } }).catch(page.onAxiosError) } page.changeToken = (params = {}) => { page.dom.innerHTML = `
` page.fadeAndScroll() page.updateTrigger(params.trigger, 'active') document.querySelector('#getNewToken').addEventListener('click', event => { if (page.isSomethingLoading) return page.warnSomethingLoading() const trigger = event.currentTarget page.updateTrigger(trigger, 'loading') axios.post('api/tokens/change').then(response => { if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { page.updateTrigger(trigger) return swal('An error occurred!', response.data.description, 'error') } page.updateTrigger(trigger) swal({ title: 'Woohoo!', text: 'Your token was successfully changed.', icon: 'success', buttons: false, timer: 1500 }).then(() => { axios.defaults.headers.common.token = response.data.token localStorage[lsKeys.token] = response.data.token page.token = response.data.token page.changeToken() }) }).catch(error => { page.updateTrigger(trigger) page.onAxiosError(error) }) }) } page.changePassword = (params = {}) => { page.dom.innerHTML = `
` page.fadeAndScroll() page.updateTrigger(params.trigger, 'active') document.querySelector('#sendChangePassword').addEventListener('click', event => { if (page.isSomethingLoading) return page.warnSomethingLoading() if (!page.dom.querySelector('form').checkValidity()) return if (document.querySelector('#password').value === document.querySelector('#passwordConfirm').value) page.sendNewPassword(document.querySelector('#password').value, event.currentTarget) else swal({ title: 'Password mismatch!', text: 'Your passwords do not match, please try again.', icon: 'error' }) }) } page.sendNewPassword = (pass, element) => { page.updateTrigger(element, 'loading') axios.post('api/password/change', { password: pass }).then(response => { if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { page.updateTrigger(element) return swal('An error occurred!', response.data.description, 'error') } page.updateTrigger(element) swal({ title: 'Woohoo!', text: 'Your password was successfully changed.', icon: 'success', buttons: false, timer: 1500 }).then(() => { page.changePassword() }) }).catch(error => { page.updateTrigger(element) page.onAxiosError(error) }) } page.getUsers = (params = {}) => { if (page.isSomethingLoading) return page.warnSomethingLoading() if (!page.permissions.admin) return swal('An error occurred!', 'You cannot do this!', 'error') page.updateTrigger(params.trigger, 'loading') if (typeof params.pageNum !== 'number') params.pageNum = 0 const url = `api/users/${params.pageNum}` axios.get(url).then(response => { if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { page.updateTrigger(params.trigger) return swal('An error occurred!', response.data.description, 'error') } const pages = Math.ceil(response.data.count / 25) const users = response.data.users if (params.pageNum && (users.length === 0)) { page.updateTrigger(params.trigger) if (params.autoPage) { params.pageNum = pages - 1 return page.getUsers(params) } else { return swal('An error occurred!', `There are no more users to populate page ${params.pageNum + 1}.`, 'error') } } page.currentView = 'users' page.cache = {} if (params.pageNum < 0) params.pageNum = Math.max(0, pages + params.pageNum) const pagination = page.paginate(response.data.count, 25, params.pageNum) const filter = `
` const extraControls = `
${filter}
` const controls = `
` // Do some string replacements for bottom controls const bottomFiltersId = 'bFilters' const bottomJumpId = 'bJumpToPage' const bottomExtraControls = extraControls .replace(/id="filters"/, `id="${bottomFiltersId}"`) .replace(/(data-action="filter-uploads")/, `$1 data-filtersid="${bottomFiltersId}"`) .replace(/id="jumpToPage"/, `id="${bottomJumpId}"`) .replace(/(data-action="jump-to-page")/g, `$1 data-jumpid="${bottomJumpId}"`) const bottomPagination = pagination .replace(/(data-action="page-ellipsis")/g, `$1 data-jumpid="${bottomJumpId}"`) // Whether there are any unselected items let unselected = false page.dom.innerHTML = ` ${pagination} ${extraControls} ${controls}
Username Uploads Usage Group Registration date Last token update
${controls} ${bottomExtraControls} ${bottomPagination} ` const table = document.querySelector('#table') for (let i = 0; i < users.length; i++) { const user = users[i] const selected = page.selected[page.currentView].includes(user.id) if (!selected) unselected = true let displayGroup = null const groups = Object.keys(user.groups) for (let i = 0; i < groups.length; i++) { if (!user.groups[groups[i]]) break displayGroup = groups[i] } // Server-side explicitly expects either of these two values to consider a user as disabled const enabled = user.enabled !== false && user.enabled !== 0 page.cache[user.id] = { username: user.username, groups: user.groups, enabled, displayGroup } const prettyDate = user.registration ? page.getPrettyDate(new Date(user.registration * 1000)) : '-' const prettyTokenUpdate = user.timestamp ? page.getPrettyDate(new Date(user.timestamp * 1000)) : '-' const tr = document.createElement('tr') tr.dataset.id = user.id tr.innerHTML = ` ${user.username} ${user.uploads} ${page.getPrettyBytes(user.usage)} ${displayGroup} ${prettyDate} ${prettyTokenUpdate} ` table.appendChild(tr) page.checkboxes = table.querySelectorAll('.checkbox[data-action="select"]') } const selectAll = document.querySelector('#selectAll') if (selectAll && !unselected) { selectAll.checked = true selectAll.title = 'Unselect all' } page.fadeAndScroll() page.updateTrigger(params.trigger, 'active') page.views[page.currentView].pageNum = users.length ? params.pageNum : 0 }).catch(error => { page.updateTrigger(params.trigger) page.onAxiosError(error) }) } page.createUser = () => { const groupOptions = Object.keys(page.permissions).map((g, i, a) => { const disabled = !(a[i + 1] && page.permissions[a[i + 1]]) return `` }).join('\n') const div = document.createElement('div') div.innerHTML = `
` swal({ title: 'Create new user', icon: 'info', content: div, buttons: { cancel: true, confirm: { closeModal: false } } }).then(proceed => { if (!proceed) return axios.post('api/users/create', { username: document.querySelector('#swalUsername').value, password: document.querySelector('#swalPassword').value, group: document.querySelector('#swalGroup').value }).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { return swal('An error occurred!', response.data.description, 'error') } const div = document.createElement('div') div.innerHTML = `

Username: ${response.data.username}

Password: ${response.data.password}

User group: ${response.data.group}

` swal({ title: 'Created a new user!', icon: 'success', content: div }) // Load last page of users list // eslint-disable-next-line compat/compat page.getUsers(Object.assign(page.views.users, { pageNum: -1 })) }).catch(page.onAxiosError) }) } page.editUser = id => { const user = page.cache[id] if (!user) return const groupOptions = Object.keys(page.permissions).map((g, i, a) => { const selected = g === user.displayGroup const disabled = !(a[i + 1] && page.permissions[a[i + 1]]) return `` }).join('\n') const div = document.createElement('div') div.innerHTML = `

User ID: ${id}

` swal({ title: 'Edit user', icon: 'info', content: div, buttons: { cancel: true, confirm: { closeModal: false } } }).then(proceed => { if (!proceed) return axios.post('api/users/edit', { id, username: document.querySelector('#swalUsername').value, group: document.querySelector('#swalGroup').value, enabled: document.querySelector('#swalEnabled').checked, resetPassword: document.querySelector('#swalResetPassword').checked }).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { return swal('An error occurred!', response.data.description, 'error') } let autoClose = true const div = document.createElement('div') let displayName = user.username if (response.data.update.username !== user.username) { div.innerHTML += `

${user.username} was renamed into: ${response.data.update.username}.

` autoClose = false displayName = response.data.update.username } if (response.data.update.password) { div.innerHTML += `

${displayName}'s new password is:

${response.data.update.password}

` autoClose = false } if (response.data.update.enabled !== user.enabled) div.innerHTML += `

${displayName} has been ${response.data.update.enabled ? 'enabled' : 'disabled'}!

` if (!div.innerHTML) div.innerHTML = `

${displayName} was edited!

` swal({ title: 'Success!', icon: 'success', content: div, buttons: !autoClose, timer: autoClose ? 1500 : null }) page.getUsers(page.views.users) }).catch(page.onAxiosError) }) } page.disableUser = id => { const user = page.cache[id] if (!user || !user.enabled) return const content = document.createElement('div') content.innerHTML = `

You will be disabling a user named ${page.cache[id].username}.

Their files will remain.

` swal({ title: 'Are you sure?', icon: 'warning', content, dangerMode: true, buttons: { cancel: true, confirm: { text: 'Yes, disable them!', closeModal: false } } }).then(proceed => { if (!proceed) return axios.post('api/users/disable', { id }).then(response => { if (!response) return if (response.data.success === false) if (response.data.description === 'No token provided') return page.verifyToken(page.token) else return swal('An error occurred!', response.data.description, 'error') swal('Success!', `${page.cache[id].username} has been disabled.`, 'success', { buttons: false, timer: 1500 }) page.getUsers(page.views.users) }).catch(page.onAxiosError) }) } page.deleteUser = id => { const user = page.cache[id] if (!user) return const content = document.createElement('div') content.innerHTML = `

You will be deleting a user named ${page.cache[id].username}.

Their files will remain, unless you choose otherwise.

` swal({ title: 'Are you sure?', icon: 'warning', content, dangerMode: true, buttons: { cancel: true, confirm: { text: 'Yes, delete it!', closeModal: false }, purge: { text: 'Yes, and the uploads too!', value: 'purge', className: 'swal-button--danger', closeModal: false } } }).then(proceed => { if (!proceed) return axios.post('api/users/delete', { id, purge: proceed === 'purge' }).then(response => { if (!response) return if (response.data.success === false) { const failed = Array.isArray(response.data.failed) ? response.data.failed : [] if (response.data.description === 'No token provided') return page.verifyToken(page.token) else if (failed.length) return swal('An error occurred!', `Unable to delete ${failed.length} of the user's upload${failed.length === 1 ? '' : 's'}.`, 'error') else return swal('An error occurred!', response.data.description, 'error') } swal('Success!', `${page.cache[id].username} has been deleted.`, 'success', { buttons: false, timer: 1500 }) // Reload users list // eslint-disable-next-line compat/compat page.getUsers(Object.assign(page.views.users, { autoPage: true })) }).catch(page.onAxiosError) }) } // Roughly based on https://github.com/mayuska/pagination/blob/master/index.js page.paginate = (totalItems, itemsPerPage, currentPage) => { currentPage = currentPage + 1 const step = 3 const numPages = Math.ceil(totalItems / itemsPerPage) let template = '' const elementsToShow = step * 2 const add = { pageNum (start, end) { for (let i = start; i <= end; ++i) template += `
  • ${i}
  • ` }, startDots () { template += `
  • 1
  • ` }, endDots () { template += `
  • ${numPages}
  • ` } } if (elementsToShow + 1 >= numPages) { add.pageNum(1, numPages) } else if (currentPage < elementsToShow) { add.pageNum(1, elementsToShow) add.endDots() } else if (currentPage > numPages - elementsToShow + 1) { add.startDots() add.pageNum(numPages - elementsToShow + 1, numPages) } else { add.startDots() add.pageNum(currentPage - step + 1, currentPage + step - 1) add.endDots() } return ` ` } page.getStatistics = (params = {}) => { if (!page.permissions.admin) return swal('An error occurred!', 'You cannot do this!', 'error') if (page.isSomethingLoading) return page.warnSomethingLoading() page.updateTrigger(params.trigger, 'loading') const url = 'api/stats' axios.get(url).then(response => { if (response.data.success === false) if (response.data.description === 'No token provided') { return page.verifyToken(page.token) } else { page.updateTrigger(params.trigger) return swal('An error occurred!', response.data.description, 'error') } let content = '' const keys = Object.keys(response.data.stats) for (let i = 0; i < keys.length; i++) { let rows = '' if (!response.data.stats[keys[i]]) rows += ` Generating, please try again later\u2026 ` else try { const types = response.data.stats[keys[i]]._types || {} const valKeys = Object.keys(response.data.stats[keys[i]]) for (let j = 0; j < valKeys.length; j++) { // Skip keys that starts with an underscore if (/^_/.test(valKeys[j])) continue const value = response.data.stats[keys[i]][valKeys[j]] let parsed = value // Parse values with some preset formatting if ((types.number || []).includes(valKeys[j])) parsed = value.toLocaleString() if ((types.byte || []).includes(valKeys[j])) parsed = page.getPrettyBytes(value) if ((types.byteUsage || []).includes(valKeys[j])) parsed = `${page.getPrettyBytes(value.used)} / ${page.getPrettyBytes(value.total)} (${Math.round(value.used / value.total * 100)}%)` const string = valKeys[j] .replace(/([A-Z])/g, ' $1') .replace(/(^|\s)(cpu|db|zip)/gi, s => s.toUpperCase()) rows += ` ${string} ${parsed} ` } } catch (error) { rows = ` Error parsing response. Try again? ` page.onError(error) } content += `
    ${rows}
    ${keys[i]}
    ` } page.dom.innerHTML = content page.fadeAndScroll() page.updateTrigger(params.trigger, 'active') }).catch(error => { page.updateTrigger(params.trigger) page.onAxiosError(error) }) } window.addEventListener('DOMContentLoaded', () => { // Polyfill Object.assign() // eslint-disable-next-line compat/compat if (typeof Object.assign !== 'function') // Must be writable: true, enumerable: false, configurable: true Object.defineProperty(Object, 'assign', { value: function assign (target, varArgs) { // .length of function is 2 'use strict' if (target === null || target === undefined) throw new TypeError('Cannot convert undefined or null to object') const to = Object(target) for (let i = 1; i < arguments.length; i++) { const nextSource = arguments[i] if (nextSource !== null && nextSource !== undefined) for (const nextKey in nextSource) // Avoid bugs when hasOwnProperty is shadowed if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) to[nextKey] = nextSource[nextKey] } return to }, writable: true, configurable: true }) // Add 'no-touch' class to non-touch devices if (!('ontouchstart' in document.documentElement)) document.documentElement.classList.add('no-touch') const selectedKeys = ['uploads', 'uploadsAll', 'albums', 'albumsAll', 'users'] for (let i = 0; i < selectedKeys.length; i++) { const ls = localStorage[lsKeys.selected[selectedKeys[i]]] if (ls) page.selected[selectedKeys[i]] = JSON.parse(ls) } page.preparePage() page.clipboardJS = new ClipboardJS('.clipboard-js') page.clipboardJS.on('success', () => { return swal('', 'The link has been copied to clipboard.', 'success', { buttons: false, timer: 1500 }) }) page.clipboardJS.on('error', page.onError) page.lazyLoad = new LazyLoad() })