diff --git a/src/components/Admintools.jsx b/src/components/Admintools.jsx index a052fb0..8df1018 100644 --- a/src/components/Admintools.jsx +++ b/src/components/Admintools.jsx @@ -13,6 +13,8 @@ const keptState = { coords: null, tlcoords: null, brcoords: null, + tlrcoords: null, + brrcoords: null, }; async function submitImageAction( @@ -57,6 +59,27 @@ async function submitProtAction( callback(await resp.text()); } +async function submitRollback( + date, + canvas, + tlcoords, + brcoords, + callback, +) { + const data = new FormData(); + const timeString = date.substr(0, 4) + date.substr(5, 2) + date.substr(8, 2); + data.append('rollback', timeString); + data.append('canvasid', canvas); + data.append('ulcoor', tlcoords); + data.append('brcoor', brcoords); + const resp = await fetch('./admintools', { + credentials: 'include', + method: 'POST', + body: data, + }); + callback(await resp.text()); +} + async function submitIPAction( action, callback, @@ -78,13 +101,23 @@ function Admintools({ canvasId, canvases, }) { + const curDate = new Date(); + let day = curDate.getDate(); + let month = curDate.getMonth() + 1; + if (month < 10) month = `0${month}`; + if (day < 10) day = `0${day}`; + const maxDate = `${curDate.getFullYear()}-${month}-${day}`; + const [selectedCanvas, selectCanvas] = useState(canvasId); const [imageAction, selectImageAction] = useState('build'); const [iPAction, selectIPAction] = useState('ban'); const [protAction, selectProtAction] = useState('protect'); + const [date, selectDate] = useState(maxDate); const [coords, selectCoords] = useState(keptState.coords); const [tlcoords, selectTLCoords] = useState(keptState.tlcoords); const [brcoords, selectBRCoords] = useState(keptState.brcoords); + const [tlrcoords, selectTLRCoords] = useState(keptState.tlrcoords); + const [brrcoords, selectBRRCoords] = useState(keptState.brrcoords); const [resp, setResp] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -129,8 +162,6 @@ function Admintools({ )} -

Image Upload

-

Upload images to canvas

Choose Canvas: 

+
+
+

Image Upload

+

Upload images to canvas

File:  @@ -221,29 +256,6 @@ function Admintools({ (if you need finer grained control,  use protect with image upload and alpha layers)

-

Choose Canvas:  - -

{ + selectDate(evt.target.value); + }} + /> +

+ Top-left corner (X_Y):  + { + const co = evt.target.value.trim(); + selectTLRCoords(co); + keptState.tlrcoords = co; + }} + /> +

+

+ Bottom-right corner (X_Y):  + { + const co = evt.target.value.trim(); + selectBRRCoords(co); + keptState.brrcoords = co; + }} + /> +

+ +

IP Actions

diff --git a/src/core/rollback.js b/src/core/rollback.js new file mode 100644 index 0000000..82b1628 --- /dev/null +++ b/src/core/rollback.js @@ -0,0 +1,115 @@ +/* + * Rolls back an area of the canvas to a specific date + * + * @flow + */ + +// Tile creation is allowed to be slow +/* eslint-disable no-await-in-loop */ + +import fs from 'fs'; +import path from 'path'; +import sharp from 'sharp'; + +import RedisCanvas from '../data/models/RedisCanvas'; +import logger from './logger'; +import { getChunkOfPixel } from './utils'; +import Palette from './Palette'; +import { TILE_SIZE } from './constants'; +import { BACKUP_DIR } from './config'; +// eslint-disable-next-line import/no-unresolved +import canvases from './canvases.json'; + +export default async function rollbackToDate( + canvasId: number, + x: number, + y: number, + width: number, + height: number, + date: string, +) { + if (!BACKUP_DIR) { + return 0; + } + const dir = path.resolve(__dirname, BACKUP_DIR); + const backupDir = `${dir}/${date}/${canvasId}/tiles`; + if (!fs.existsSync(backupDir)) { + return 0; + } + + logger.info( + `Rollback area ${width}/${height} to ${x}/${y}/${canvasId} to date ${date}`, + ); + const canvas = canvases[canvasId]; + const { colors, size } = canvas; + const palette = new Palette(colors); + const canvasMinXY = -(size / 2); + + const [ucx, ucy] = getChunkOfPixel(size, x, y); + const [lcx, lcy] = getChunkOfPixel(size, x + width, y + height); + + let totalPxlCnt = 0; + logger.info(`Loading to chunks from ${ucx} / ${ucy} to ${lcx} / ${lcy} ...`); + let chunk; + let empty = false; + let emptyBackup = false; + let backupChunk; + for (let cx = ucx; cx <= lcx; cx += 1) { + for (let cy = ucy; cy <= lcy; cy += 1) { + chunk = await RedisCanvas.getChunk(canvasId, cx, cy); + if (chunk && chunk.length === TILE_SIZE * TILE_SIZE) { + chunk = new Uint8Array(chunk); + empty = false; + } else { + chunk = new Uint8Array(TILE_SIZE * TILE_SIZE); + empty = true; + } + try { + emptyBackup = false; + backupChunk = await sharp(`${backupDir}/${cx}/${cy}.png`) + .ensureAlpha() + .raw() + .toBuffer(); + backupChunk = new Uint32Array(backupChunk.buffer); + } catch { + logger.info( + // eslint-disable-next-line max-len + `Backup chunk ${backupDir}/${cx}/${cy}.png could not be loaded, assuming empty.`, + ); + emptyBackup = true; + } + let pxlCnt = 0; + if (!empty || !emptyBackup) { + // offset of chunk in image + const cOffX = cx * TILE_SIZE + canvasMinXY - x; + const cOffY = cy * TILE_SIZE + canvasMinXY - y; + let cOff = 0; + for (let py = 0; py < TILE_SIZE; py += 1) { + for (let px = 0; px < TILE_SIZE; px += 1) { + const clrX = cOffX + px; + const clrY = cOffY + py; + if (clrX >= 0 && clrY >= 0 && clrX < width && clrY < height) { + const pixel = (emptyBackup) + ? 0 : palette.abgr.indexOf(backupChunk[cOff]); + if (pixel !== -1) { + chunk[cOff] = pixel; + pxlCnt += 1; + } + } + cOff += 1; + } + } + } + if (pxlCnt) { + const ret = await RedisCanvas.setChunk(cx, cy, chunk, canvasId); + if (ret) { + logger.info(`Loaded ${pxlCnt} pixels into chunk ${cx}, ${cy}.`); + totalPxlCnt += pxlCnt; + } + } + chunk = null; + } + } + logger.info('Rollback done.'); + return totalPxlCnt; +} diff --git a/src/routes/admintools.js b/src/routes/admintools.js index 775f1d6..da688cd 100644 --- a/src/routes/admintools.js +++ b/src/routes/admintools.js @@ -29,6 +29,7 @@ import { imageABGR2Canvas, protectCanvasArea, } from '../core/Image'; +import rollbackCanvasArea from '../core/rollback'; const router = express.Router(); @@ -229,7 +230,7 @@ async function executeImageAction( * Execute actions for protecting areas * @param action what to do * @param ulcoor coords of upper-left corner in X_Y format - * @param brcoord coords of bottom-right corner in X_Y format + * @param brcoor coords of bottom-right corner in X_Y format * @param canvasid numerical canvas id as string * @return [ret, msg] http status code and message */ @@ -274,8 +275,6 @@ async function executeProtAction( error = 'No imageaction given'; } else if (!canvas) { error = 'Invalid canvas selected'; - } else if (!canvases[canvasid]) { - error = 'Invalid canvas selected'; } else if (action !== 'protect' && action !== 'unprotect') { error = 'Invalid action (must be protect or unprotect)'; } @@ -319,6 +318,98 @@ async function executeProtAction( ]; } +/* + * Execute rollback + * @param date in format YYYYMMdd + * @param ulcoor coords of upper-left corner in X_Y format + * @param brcoor coords of bottom-right corner in X_Y format + * @param canvasid numerical canvas id as string + * @return [ret, msg] http status code and message + */ +async function executeRollback( + date: string, + ulcoor: string, + brcoor: string, + canvasid: number, +) { + if (!ulcoor || !brcoor) { + return [403, 'Not all coordinates defined']; + } + if (!canvasid) { + return [403, 'canvasid not defined']; + } + + let splitCoords = ulcoor.trim().split('_'); + if (splitCoords.length !== 2) { + return [403, 'Invalid Coordinate Format for top-left corner']; + } + const [x, y] = splitCoords.map((z) => Math.floor(Number(z))); + splitCoords = brcoor.trim().split('_'); + if (splitCoords.length !== 2) { + return [403, 'Invalid Coordinate Format for bottom-right corner']; + } + const [u, v] = splitCoords.map((z) => Math.floor(Number(z))); + + const canvas = canvases[canvasid]; + + let error = null; + if (Number.isNaN(x)) { + error = 'x of top-left corner is not a valid number'; + } else if (Number.isNaN(y)) { + error = 'y of top-left corner is not a valid number'; + } else if (Number.isNaN(u)) { + error = 'x of bottom-right corner is not a valid number'; + } else if (Number.isNaN(v)) { + error = 'y of bottom-right corner is not a valid number'; + } else if (u < x || v < y) { + error = 'Corner coordinates are alligned wrong'; + } else if (!date) { + error = 'No date given'; + } else if (Number.isNaN(Number(date))) { + error = 'Invalid date'; + } else if (!canvas) { + error = 'Invalid canvas selected'; + } + if (error !== null) { + return [403, error]; + } + + const canvasMaxXY = canvas.size / 2; + const canvasMinXY = -canvasMaxXY; + if (x < canvasMinXY || y < canvasMinXY + || x >= canvasMaxXY || y >= canvasMaxXY) { + return [403, 'Coordinates of top-left corner are outside of canvas']; + } + if (u < canvasMinXY || v < canvasMinXY + || u >= canvasMaxXY || v >= canvasMaxXY) { + return [403, 'Coordinates of bottom-right corner are outside of canvas']; + } + + const width = u - x + 1; + const height = v - y + 1; + if (width * height > 1000000) { + return [403, 'Can not rollback more than 1m pixels at onec']; + } + + const pxlCount = await rollbackCanvasArea( + canvasid, + x, + y, + width, + height, + date, + ); + logger.info( + // eslint-disable-next-line max-len + `ADMINTOOLS: Rollback to ${date} for ${pxlCount} pixels at ${x} / ${y} with dimension ${width}x${height}`, + ); + return [ + 200, + // eslint-disable-next-line max-len + `Successfully rolled back ${pxlCount} pixels at ${x} / ${y} with dimension ${width}x${height}`, + ]; +} + /* * Check for POST parameters, @@ -351,6 +442,19 @@ router.post('/', upload.single('image'), async (req, res, next) => { ); res.status(ret).send(msg); return; + } if (req.body.rollback) { + // rollback is date as YYYYMMdd + const { + rollback, ulcoor, brcoor, canvasid, + } = req.body; + const [ret, msg] = await executeRollback( + rollback, + ulcoor, + brcoor, + canvasid, + ); + res.status(ret).send(msg); + return; } next();