001// -------------------------------------------------------------------------------- 002// Copyright 2002-2024 Echo Three, LLC 003// 004// Licensed under the Apache License, Version 2.0 (the "License"); 005// you may not use this file except in compliance with the License. 006// You may obtain a copy of the License at 007// 008// http://www.apache.org/licenses/LICENSE-2.0 009// 010// Unless required by applicable law or agreed to in writing, software 011// distributed under the License is distributed on an "AS IS" BASIS, 012// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013// See the License for the specific language governing permissions and 014// limitations under the License. 015// -------------------------------------------------------------------------------- 016 017package com.echothree.model.control.graphql.server.util.count; 018 019import com.echothree.util.server.validation.Validator; 020import com.google.common.base.Charsets; 021import com.google.common.io.BaseEncoding; 022import java.util.regex.Pattern; 023 024public final class GraphQlCursorUtils { 025 026 private static class GraphQlCursorUtilsHolder { 027 static GraphQlCursorUtils instance = new GraphQlCursorUtils(); 028 } 029 030 public static GraphQlCursorUtils getInstance() { 031 return GraphQlCursorUtilsHolder.instance; 032 } 033 034 private final BaseEncoding baseEncoding = BaseEncoding.base64(); 035 private final Pattern cursorPattern = Pattern.compile("^([a-zA-Z0-9-_]+)\\/([a-zA-Z0-9-_]+)\\/([0-9]+)$"); 036 private final byte xorValue = (byte)0b10101010; 037 038 // Intended to be as "basic" as it appears - just to prevent casual tampering. 039 private byte[] xorBytes(byte[] originalBytes) { 040 byte[] modifiedBytes = new byte[originalBytes.length]; 041 042 for(int i = 0; i < originalBytes.length; i++) { 043 modifiedBytes[i] = (byte)(originalBytes[i] ^ xorValue); 044 } 045 046 return modifiedBytes; 047 } 048 049 public Long fromCursor(final String componentVendorName, final String entityTypeName, final String cursor) { 050 Long offset = null; 051 052 // If it cannot be decoded, offset should remain null. 053 if(cursor != null && baseEncoding.canDecode(cursor)) { 054 var byteCursor = baseEncoding.decode(cursor); 055 var xoredCursor = xorBytes(byteCursor); 056 var unencodedCursor = new String(xoredCursor, Charsets.UTF_8); 057 var matcher = cursorPattern.matcher(unencodedCursor); 058 059 // If it fails to match against cursorPattern, offset should remain null. 060 if(matcher.matches()) { 061 var foundComponentVendorName = matcher.group(1); 062 var foundEntityTypeName = matcher.group(2); 063 064 if(componentVendorName.equals(foundComponentVendorName) && entityTypeName.equals(foundEntityTypeName)) { 065 var unvalidatedCursor = matcher.group(3); 066 var validatedCursor = Validator.validateUnsignedLong(unvalidatedCursor); 067 068 offset = validatedCursor == null ? null : Long.valueOf(validatedCursor); 069 } 070 } 071 } 072 073 return offset; 074 } 075 076 public String toCursor(final String componentVendorName, final String entityTypeName, final long offset) { 077 var unencodedCursor = componentVendorName + '/' + entityTypeName + '/' + offset; 078 var byteCursor = unencodedCursor.getBytes(Charsets.UTF_8); 079 var xoredCursor = xorBytes(byteCursor); 080 081 return baseEncoding.encode(xoredCursor); 082 } 083 084}