001// -------------------------------------------------------------------------------- 002// Copyright 2002-2025 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.nio.charset.StandardCharsets; 023import java.util.regex.Pattern; 024 025public final class GraphQlCursorUtils { 026 027 private static class GraphQlCursorUtilsHolder { 028 static GraphQlCursorUtils instance = new GraphQlCursorUtils(); 029 } 030 031 public static GraphQlCursorUtils getInstance() { 032 return GraphQlCursorUtilsHolder.instance; 033 } 034 035 private final BaseEncoding baseEncoding = BaseEncoding.base64(); 036 private final Pattern cursorPattern = Pattern.compile("^([a-zA-Z0-9-_]+)\\/([a-zA-Z0-9-_]+)\\/([0-9]+)$"); 037 private final byte xorValue = (byte)0b10101010; 038 039 // Intended to be as "basic" as it appears - just to prevent casual tampering. 040 private byte[] xorBytes(byte[] originalBytes) { 041 var modifiedBytes = new byte[originalBytes.length]; 042 043 for(var i = 0; i < originalBytes.length; i++) { 044 modifiedBytes[i] = (byte)(originalBytes[i] ^ xorValue); 045 } 046 047 return modifiedBytes; 048 } 049 050 public Long fromCursor(final String componentVendorName, final String entityTypeName, final String cursor) { 051 Long offset = null; 052 053 // If it cannot be decoded, offset should remain null. 054 if(cursor != null && baseEncoding.canDecode(cursor)) { 055 var byteCursor = baseEncoding.decode(cursor); 056 var xoredCursor = xorBytes(byteCursor); 057 var unencodedCursor = new String(xoredCursor, StandardCharsets.UTF_8); 058 var matcher = cursorPattern.matcher(unencodedCursor); 059 060 // If it fails to match against cursorPattern, offset should remain null. 061 if(matcher.matches()) { 062 var foundComponentVendorName = matcher.group(1); 063 var foundEntityTypeName = matcher.group(2); 064 065 if(componentVendorName.equals(foundComponentVendorName) && entityTypeName.equals(foundEntityTypeName)) { 066 var unvalidatedCursor = matcher.group(3); 067 var validatedCursor = Validator.validateUnsignedLong(unvalidatedCursor); 068 069 offset = validatedCursor == null ? null : Long.valueOf(validatedCursor); 070 } 071 } 072 } 073 074 return offset; 075 } 076 077 public String toCursor(final String componentVendorName, final String entityTypeName, final long offset) { 078 var unencodedCursor = componentVendorName + '/' + entityTypeName + '/' + offset; 079 var byteCursor = unencodedCursor.getBytes(StandardCharsets.UTF_8); 080 var xoredCursor = xorBytes(byteCursor); 081 082 return baseEncoding.encode(xoredCursor); 083 } 084 085}