feat: usergroup-based file retention periods

this supersedes the old temporaryUploadAges, while maintaining full
backwards-compatibility.

please consult config.sample.js if you want to start using this
This commit is contained in:
Bobby 2022-05-07 02:17:31 +07:00
parent 4d4dffc5ae
commit 2d147e748b
No known key found for this signature in database
GPG Key ID: 941839794CBF5A09
8 changed files with 204 additions and 53 deletions

View File

@ -369,31 +369,82 @@ module.exports = {
Default age will be the value at the very top of the array.
If the array is populated but do not have a zero value,
permanent uploads will be rejected.
This only applies to new files uploaded after enabling the option.
attempts to set permanent upload age will be rejected.
If the array is empty or is set to falsy value, temporary uploads
feature will be disabled, and all uploads will be permanent (original behavior).
This only applies to new files uploaded AFTER enabling the option.
If disabled, any existing temporary uploads will not ever be automatically deleted,
since the safe assumes all uploads are permanent,
and thus will not start the periodical check up task.
When temporary uploads feature is disabled, any existing temporary uploads
will not ever be automatically deleted, since the safe will not start the
periodical checkup task.
DEPRECATED: Please use "retentionPeriods" option below instead.
*/
temporaryUploadAges: [
0, // permanent
1 / 60 * 15, // 15 minutes
1 / 60 * 30, // 30 minutes
1, // 1 hour
6, // 6 hours
12, // 12 hours
24, // 24 hours (1 day)
24 * 2, // 48 hours (2 days)
24 * 3, // 72 hours (3 days)
24 * 4, // 96 hours (4 days)
24 * 5, // 120 hours (5 days)
24 * 6, // 144 hours (6 days)
24 * 7 // 168 hours (7 days)
],
// temporaryUploadAges: [],
/*
Usergroup-based file retention periods (temporary uploads ages).
You need to at least configure the default group (_), or any one group, to enable this.
If this is enabled, "temporaryUploadAges" option above will be completely ignored.
It's safe to disable and remove that option completely if you plan to only use this one.
The support for it was only kept as backwards-compatibility for older installations.
This only applies to new files uploaded AFTER enabling the option.
If disabled, any existing temporary uploads will not ever be automatically deleted,
since the safe assumes all uploads are permanent,
and thus will not start the periodical check up task.
Please refer to the examples below about inheritances
and how to set default retention for each groups.
*/
retentionPeriods: {
// Defaults that also apply to non-registered users
_: [
24, // 24 hours (1 day) -- first value is the group's default retention
1 / 60 * 15, // 15 minutes
1 / 60 * 30, // 30 minutes
1, // 1 hour
6, // 6 hours
12 // 12 hours
],
/*
Inheritance is based on each group's 'values' in permissionController.js.
Basically groups with higher 'value' will inherit retention periods
of any groups with lower 'values'.
You may remove all the groups below to apply the defaults above for everyone.
*/
user: [
24 * 7, // 168 hours (7 days) -- group's default
24 * 2, // 48 hours (2 days)
24 * 3, // 72 hours (3 days)
24 * 4, // 96 hours (4 days)
24 * 5, // 120 hours (5 days)
24 * 6 // 144 hours (6 days)
],
vip: [
24 * 30, // 720 hours (30 days) -- group's default
24 * 14, // 336 hours (14 days)
24 * 21, // 504 hours (21 days)
24 * 91 // 2184 hours (91 days)
],
vvip: [
null, // -- if null, use previous group's default as this group's default
0, // permanent
24 * 183 // 4392 hours (183 days)
],
moderator: [
0 // -- group's default
/*
vvip group also have 0 (permanent) in its retention periods,
but duplicates are perfectly fine and will be safely 'uniquified',
while still properly maintaining defaults when required.
*/
]
/*
Missing groups will follow the inheritance rules.
Following the example above, admin and superadmin will have the same retention periods as moderator.
*/
},
/*
Interval of the periodical check up tasks for temporary uploads (in milliseconds).

View File

@ -2,6 +2,8 @@ const self = {}
self.permissions = Object.freeze({
user: 0, // Upload & delete own files, create & delete albums
vip: 5, // If used with "retentionPeriods" in config, may have additional retention period options
vvip: 10, // If used with "retentionPeriods" in config, may have additional retention period options
moderator: 50, // Delete other user's files
admin: 80, // Manage users (disable accounts) & create moderators
superadmin: 100 // Create admins

View File

@ -59,6 +59,15 @@ self.verify = async (req, res, next) => {
permissions: perms.mapPermissions(user)
}
const group = perms.group(user)
if (group) {
obj.group = group
if (utils.retentions.enabled) {
obj.retentionPeriods = utils.retentions.periods[group]
obj.defaultRetentionPeriod = utils.retentions.default[group]
}
}
if (utils.clientVersion) {
obj.version = utils.clientVersion
}

View File

@ -48,8 +48,6 @@ const extensionsFilter = Array.isArray(config.extensionsFilter) &&
config.extensionsFilter.length
const urlExtensionsFilter = Array.isArray(config.uploads.urlExtensionsFilter) &&
config.uploads.urlExtensionsFilter.length
const temporaryUploads = Array.isArray(config.uploads.temporaryUploadAges) &&
config.uploads.temporaryUploadAges.length
/** Chunks helper class & function **/
@ -244,17 +242,29 @@ self.getUniqueRandomName = async (length, extension) => {
throw new ServerError('Failed to allocate a unique name for the upload. Try again?')
}
self.parseUploadAge = age => {
if (age === undefined || age === null) {
return config.uploads.temporaryUploadAges[0]
self.assertRetentionPeriod = (user, age) => {
if (!utils.retentions.enabled) return null
const group = user ? perms.group(user) : '_'
if (!group || !utils.retentions.periods[group]) {
throw new ClientError('You are not eligible for any file retention periods.', { statusCode: 403 })
}
const parsed = parseFloat(age)
if (config.uploads.temporaryUploadAges.includes(parsed)) {
return parsed
let parsed = null
if (age === undefined || age === null) {
parsed = utils.retentions.default[group]
} else {
return null
parsed = parseFloat(age)
if (!utils.retentions.periods[group].includes(parsed)) {
throw new ClientError('You are not eligible for the specified file retention period.', { statusCode: 403 })
}
}
if (!parsed && !utils.retentions.periods[group].includes(0)) {
throw new ClientError('Permanent uploads are not permitted.', { statusCode: 403 })
}
return parsed
}
self.parseStripTags = stripTags => {
@ -287,13 +297,7 @@ self.upload = async (req, res, next) => {
let albumid = parseInt(req.headers.albumid || req.params.albumid)
if (isNaN(albumid)) albumid = null
let age = null
if (temporaryUploads) {
age = self.parseUploadAge(req.headers.age)
if (!age && !config.uploads.temporaryUploadAges.includes(0)) {
throw new ClientError('Permanent uploads are not permitted.', { statusCode: 403 })
}
}
const age = self.assertRetentionPeriod(user, req.headers.age)
const func = req.body.urls ? self.actuallyUploadUrls : self.actuallyUploadFiles
await func(req, res, user, albumid, age)
@ -539,12 +543,7 @@ self.actuallyFinishChunks = async (req, res, user) => {
throw new ClientError(`${file.extname ? `${file.extname.substr(1).toUpperCase()} files` : 'Files with no extension'} are not permitted.`)
}
if (temporaryUploads) {
file.age = self.parseUploadAge(file.age)
if (!file.age && !config.uploads.temporaryUploadAges.includes(0)) {
throw new ClientError('Permanent uploads are not permitted.')
}
}
file.age = self.assertRetentionPeriod(user, file.age)
file.size = chunksData[file.uuid].stream.bytesWritten
if (config.filterEmptyFile && file.size === 0) {
@ -1455,7 +1454,7 @@ self.list = async (req, res, next) => {
else if (offset < 0) offset = Math.max(0, Math.ceil(count / 25) + offset)
const columns = ['id', 'name', 'original', 'userid', 'size', 'timestamp']
if (temporaryUploads) columns.push('expirydate')
if (utils.retentions.enabled) columns.push('expirydate')
if (!all ||
filterObj.queries.albumid ||
filterObj.queries.exclude.albumid ||

View File

@ -52,7 +52,13 @@ const self = {
ffprobe: promisify(ffmpeg.ffprobe),
albumsCache: {},
timezoneOffset: new Date().getTimezoneOffset()
timezoneOffset: new Date().getTimezoneOffset(),
retentions: {
enabled: false,
periods: {},
default: {}
}
}
// Remember old renderer, if overridden, or proxy to default renderer
@ -71,6 +77,75 @@ self.md.instance.renderer.rules.link_open = function (tokens, idx, options, env,
return self.md.defaultRenderers.link_open(tokens, idx, options, env, that)
}
if (typeof config.uploads.retentionPeriods === 'object' &&
Object.keys(config.uploads.retentionPeriods).length) {
// Build a temporary index of group values
const _retentionPeriods = Object.assign({}, config.uploads.retentionPeriods)
const _groups = { _: -1 }
Object.assign(_groups, perms.permissions)
// Sanitize config values
const names = Object.keys(_groups)
for (const name of names) {
if (Array.isArray(_retentionPeriods[name]) && _retentionPeriods[name].length) {
_retentionPeriods[name] = _retentionPeriods[name]
.filter((v, i, a) => (Number.isFinite(v) && v >= 0) || v === null)
} else {
_retentionPeriods[name] = []
}
}
if (!_retentionPeriods._.length && !config.private) {
logger.error('Guests\' retention periods are missing, yet this installation is not set to private.')
process.exit(1)
}
// Create sorted array of group names based on their values
const _sorted = Object.keys(_groups)
.sort((a, b) => _groups[a] - _groups[b])
// Build retention periods array for each groups
for (let i = 0; i < _sorted.length; i++) {
const current = _sorted[i]
const _periods = [..._retentionPeriods[current]]
self.retentions.default[current] = _periods.length ? _periods[0] : null
if (i > 0) {
// Inherit retention periods of lower-valued groups
for (let j = i - 1; j >= 0; j--) {
const lower = _sorted[j]
if (_groups[lower] < _groups[current]) {
_periods.unshift(..._retentionPeriods[lower])
if (self.retentions.default[current] === null) {
self.retentions.default[current] = self.retentions.default[lower]
}
}
}
}
self.retentions.periods[current] = _periods
.filter((v, i, a) => v !== null && a.indexOf(v) === i) // re-sanitize & uniquify
.sort((a, b) => a - b) // sort from lowest to highest (zero/permanent will always be first)
// Mark the feature as enabled, if at least one group was configured
if (self.retentions.periods[current].length) {
self.retentions.enabled = true
}
}
} else if (Array.isArray(config.uploads.temporaryUploadAges) &&
config.uploads.temporaryUploadAges.length) {
self.retentions.periods._ = config.uploads.temporaryUploadAges
.filter((v, i, a) => Number.isFinite(v) && v >= 0)
self.retentions.default._ = self.retentions.periods._[0]
for (const name of Object.keys(perms.permissions)) {
self.retentions.periods[name] = self.retentions.periods._
self.retentions.default[name] = self.retentions.default._
}
self.retentions.enabled = true
}
const statsData = {
system: {
title: 'System',

View File

@ -372,10 +372,8 @@ safe.use('/api', api)
}
}
// Temporary uploads (only check for expired uploads if config.uploads.temporaryUploadsInterval is also set)
if (Array.isArray(config.uploads.temporaryUploadAges) &&
config.uploads.temporaryUploadAges.length &&
config.uploads.temporaryUploadsInterval) {
// Initiate internal periodical check ups of temporary uploads if required
if (utils.retentions && utils.retentions.enabled && config.uploads.temporaryUploadsInterval > 0) {
let temporaryUploadsInProgress = false
const temporaryUploadCheck = async () => {
if (temporaryUploadsInProgress) return

View File

@ -12,11 +12,16 @@ routes.get('/check', (req, res, next) => {
enableUserAccounts: config.enableUserAccounts,
maxSize: config.uploads.maxSize,
chunkSize: config.uploads.chunkSize,
temporaryUploadAges: config.uploads.temporaryUploadAges,
fileIdentifierLength: config.uploads.fileIdentifierLength,
stripTags: config.uploads.stripTags
}
if (utilsController.clientVersion) obj.version = utilsController.clientVersion
if (utilsController.retentions.enabled && utilsController.retentions.periods._) {
obj.temporaryUploadAges = utilsController.retentions.periods._
obj.defaultTemporaryUploadAge = utilsController.retentions.default._
}
if (utilsController.clientVersion) {
obj.version = utilsController.clientVersion
}
return res.json(obj)
})

View File

@ -22,6 +22,7 @@ const page = {
maxSize: null,
chunkSizeConfig: null,
temporaryUploadAges: null,
defaultTemporaryUploadAge: null,
fileIdentifierLength: null,
stripTagsConfig: null,
@ -182,6 +183,7 @@ page.checkIfPublic = () => {
}
page.temporaryUploadAges = response.data.temporaryUploadAges
page.defaultTemporaryUploadAge = response.data.defaultTemporaryUploadAge || null
page.fileIdentifierLength = response.data.fileIdentifierLength
page.stripTagsConfig = response.data.stripTags
@ -210,6 +212,13 @@ page.verifyToken = token => {
return axios.post('api/tokens/verify', { token }).then(response => {
localStorage[lsKeys.token] = token
page.token = token
// If user has its own retention periods array, override defaults
if (Array.isArray(response.data.retentionPeriods)) {
page.temporaryUploadAges = response.data.retentionPeriods
page.defaultTemporaryUploadAge = response.data.defaultRetentionPeriod
}
return page.prepareUpload()
}).catch(error => {
return swal({
@ -917,11 +926,14 @@ page.prepareUploadConfig = () => {
}
if (temporaryUploadAges) {
const _default = page.defaultTemporaryUploadAge === null
? page.temporaryUploadAges[0]
: page.defaultTemporaryUploadAge
const stored = parseFloat(localStorage[lsKeys.uploadAge])
for (let i = 0; i < page.temporaryUploadAges.length; i++) {
const age = page.temporaryUploadAges[i]
config.uploadAge.select.push({
value: i === 0 ? 'default' : String(age),
value: age === _default ? 'default' : String(age),
text: page.getPrettyUploadAge(age)
})
if (age === stored) {