67 Commits

Author SHA1 Message Date
Renovate Bot
4e8fea6f1f chore(deps): update dependency tailwindcss to v3.4.17 2024-12-19 10:03:34 +00:00
f5372c153c Merge pull request 'fix(deps): update dependency net.dv8tion:jda to v5.2.1' (#2) from renovate/net.dv8tion-jda-5.x into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Failing after 9s
Reviewed-on: #2
2024-12-18 23:25:50 -08:00
509c60e632 Merge pull request 'fix(deps): update dependency org.springframework.boot:spring-boot-starter-parent to v3.4.0' (#3) from renovate/spring-boot into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Has been cancelled
Reviewed-on: #3
2024-12-18 23:25:44 -08:00
247b2a6d6f Merge pull request 'chore(deps): update dependency org.apache.maven.plugins:maven-compiler-plugin to v3.13.0' (#5) from renovate/org.apache.maven.plugins-maven-compiler-plugin-3.x into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Has been cancelled
Reviewed-on: #5
2024-12-18 23:25:37 -08:00
c0e2489828 Merge pull request 'chore(deps): update dependency org.apache.maven.plugins:maven-shade-plugin to v3.6.0' (#6) from renovate/org.apache.maven.plugins-maven-shade-plugin-3.x into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Has been cancelled
Reviewed-on: #6
2024-12-18 23:25:20 -08:00
980eaf05ba Merge pull request 'fix(deps): update dependency com.github.oshi:oshi-core to v6.6.5' (#9) from renovate/com.github.oshi-oshi-core-6.x into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Has been cancelled
Reviewed-on: #9
2024-12-18 23:24:52 -08:00
ed823e9391 Merge pull request 'fix(deps): update dependency com.google.guava:guava to v32.1.3-jre' (#10) from renovate/guava-monorepo into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Has been cancelled
Reviewed-on: #10
2024-12-18 23:24:45 -08:00
b8cd5b299d Merge pull request 'fix(deps): update dependency com.squareup.okhttp3:okhttp to v4.12.0' (#11) from renovate/okhttp-monorepo into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Has been cancelled
Reviewed-on: #11
2024-12-18 23:24:35 -08:00
Renovate Bot
768d4487fb fix(deps): update dependency org.springframework.boot:spring-boot-starter-parent to v3.4.0 2024-12-12 07:08:27 +00:00
Renovate Bot
8325b98b8b fix(deps): update dependency net.dv8tion:jda to v5.2.1 2024-11-10 17:01:55 +00:00
Renovate Bot
256b7d3d34 fix(deps): update dependency com.squareup.okhttp3:okhttp to v4.12.0 2024-11-07 20:10:56 +00:00
Renovate Bot
460d454f08 fix(deps): update dependency com.github.oshi:oshi-core to v6.6.5 2024-11-07 11:10:56 +00:00
Renovate Bot
bd0a2e907d fix(deps): update dependency com.google.guava:guava to v32.1.3-jre 2024-11-06 23:04:10 +00:00
Renovate Bot
968602ac0d chore(deps): update dependency org.apache.maven.plugins:maven-shade-plugin to v3.6.0 2024-09-09 19:26:30 +00:00
Renovate Bot
409f0ce80a chore(deps): update dependency org.apache.maven.plugins:maven-compiler-plugin to v3.13.0 2024-03-18 19:01:38 +00:00
11ba593439 Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into master
Some checks failed
Publish / docker (ubuntu-latest, 2.38.4) (push) Failing after 30s
Reviewed-on: #1
2024-03-17 19:24:04 -07:00
Renovate Bot
ce90040ab7 chore(deps): add renovate.json 2024-03-18 02:21:55 +00:00
50e64a00c5 Remove this for now 2023-12-02 20:40:20 -05:00
0eb8ef6824 Initial Frontend Base 2023-12-02 04:48:37 -05:00
56ca2f5fd5 Add badges to README.md 2023-12-02 04:15:07 -05:00
Braydon
7c6e3b470b Update Example-Java/README.md 2023-12-02 01:12:40 -08:00
16968427b3 oops x.x 2023-12-02 04:08:46 -05:00
8d97e0efa4 Example -> Example-Java 2023-12-02 04:08:12 -05:00
df004e59e2 Bump lombok ver 2023-12-02 03:59:06 -05:00
b6f43a6510 Add docker-compose file 2023-12-02 03:57:30 -05:00
e1a829664a oops, forgot this 2023-12-02 03:53:11 -05:00
c6f968851a Add plan & latest product ver to licenses 2023-12-02 03:52:57 -05:00
f32a92fe54 Change ownership in licenses 2023-12-02 03:44:58 -05:00
49b8fe39a5 Add cryptography 2023-12-02 03:26:57 -05:00
9215ac87b0 Clean this up 2023-12-02 00:59:46 -05:00
d7dc635ffb Fix last used 2023-12-02 00:30:05 -05:00
ab98d752c8 put back onto another thread 2023-12-02 00:26:18 -05:00
3e16f212da test 2023-12-02 00:22:37 -05:00
e391d3e48e test 2023-12-02 00:15:50 -05:00
67a88d3366 test 2023-12-02 00:11:44 -05:00
97aea0dafc bump JDA ver 2023-12-02 00:08:22 -05:00
be0825bf15 yes no? 2023-12-02 00:02:21 -05:00
ce6abbdf55 yes no? 2023-12-02 00:01:08 -05:00
f95296db06 Update workflow file 2023-12-01 23:51:29 -05:00
5c7c7e9f68 will this work on the main thread? 2023-12-01 23:46:48 -05:00
1eda57a7e3 temp create default key when no others exist 2023-06-14 20:10:14 -04:00
57b5c4e05d Improve bot service 2023-06-14 20:10:04 -04:00
2e1e2f5127 Bump app ver and move to new JDA ver 2023-06-14 20:06:00 -04:00
803b763198 Update README.md 2023-06-08 03:05:10 -04:00
fec32230fe Add license actions 2023-06-02 05:50:27 -04:00
68591c90c9 Add a command to view your owned license 2023-06-02 05:10:41 -04:00
5133ab688f Version bump 2023-06-02 05:07:13 -04:00
36673af4d0 Only inform license owners of new IPs and HWIDs if the license was actually used 2023-06-02 00:49:41 -04:00
3038e14b81 Update README 2023-06-02 00:44:10 -04:00
2920a42d76 Update README 2023-06-02 00:42:51 -04:00
c350138caa Simple HWID validation 2023-06-02 00:39:19 -04:00
cf932e2d90 oops, forgot this 2023-06-02 00:35:55 -04:00
6b6958640f Cleanup 2023-06-02 00:32:42 -04:00
e3b6507a6c Document DiscordService#sendOwnerLog 2023-06-02 00:32:27 -04:00
1b482b93e2 Update example 2023-06-02 00:29:45 -04:00
767646feae remove unused config option 2023-06-02 00:26:52 -04:00
c28694c878 Change to a better expiration system 2023-06-02 00:25:50 -04:00
18ce6548f6 Added logging for license owners 2023-06-01 23:43:26 -04:00
b41505a2b6 Version bump 2023-06-01 22:37:09 -04:00
091bb8ac4e Update Docker deployment examples in the README 2023-06-01 01:32:01 -04:00
d6f8e2cbcf Add Discord preview to the README 2023-06-01 01:29:21 -04:00
9c8fec5fd6 Include responses in API reference 2023-06-01 01:23:12 -04:00
2c08a08003 fix syntax? 2023-06-01 01:21:15 -04:00
1bcdae67f2 Merge branch 'master' of https://github.com/Rainnny7/LicenseServer
# Conflicts:
#	README.md
2023-06-01 01:20:27 -04:00
2a5351484a Moved docker-compose.yml to the README file 2023-06-01 01:17:53 -04:00
982ff08f11 Update README.md 2023-06-01 01:17:13 -04:00
05cc42b240 Update README.md 2023-06-01 00:55:53 -04:00
50 changed files with 5045 additions and 140 deletions

View File

@ -1,4 +1,4 @@
name: Publish Image
name: Publish
on:
push:
@ -10,16 +10,18 @@ jobs:
matrix:
arch: [ "ubuntu-latest" ]
git-version: [ "2.38.4" ]
runs-on: ${{ matrix.arch }}
container: git.rainnny.club/rainnny/gitea-runner:node-18
container: fascinated/docker-images:nodejs_20
steps:
# Checkout the branch
- name: Checkout
uses: https://github.com/actions/checkout@v3
# Setup Docker BuildX
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v2
# Login to Docker
- name: Login to Repo
uses: https://github.com/docker/login-action@v2
with:
@ -27,6 +29,7 @@ jobs:
username: ${{ secrets.REPO_USERNAME }}
password: ${{ secrets.REPO_TOKEN }}
# Build & Push to Docker
- name: Build and Push
uses: docker/build-push-action@v4
with:

3
.gitignore vendored
View File

@ -24,4 +24,5 @@ com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
git.properties
git.properties
CLI

View File

@ -14,7 +14,7 @@ COPY src ./src
RUN mvn clean package -T12
# Stage 2: Running
FROM openjdk:17.0.1-jdk-slim
FROM openjdk:17.0.2-jdk-slim
# Set the work dir inside the container
WORKDIR /usr/local/app

2
Example-Java/README.md Normal file
View File

@ -0,0 +1,2 @@
# Java Example
This is the example of how to interact with the license server from within Java.

View File

@ -20,7 +20,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
@ -30,7 +30,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.1.0</version>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
@ -48,7 +48,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
@ -64,7 +64,7 @@
<dependency>
<groupId>com.github.oshi</groupId>
<artifactId>oshi-core</artifactId>
<version>6.4.2</version>
<version>6.6.5</version>
<scope>compile</scope>
</dependency>
@ -72,7 +72,7 @@
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.11.0</version>
<version>4.12.0</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@ -1,13 +1,15 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.example;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import lombok.*;
import okhttp3.*;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
@ -15,7 +17,19 @@ import oshi.hardware.ComputerSystem;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.software.os.OperatingSystem;
import javax.crypto.Cipher;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@ -29,51 +43,93 @@ import java.util.Map;
* @author Braydon
* @see <a href="https://git.rainnny.club/Rainnny/LicenseServer">License Server</a>
*/
public final class LicenseExample {
public final class LicenseClient {
private static final String ALGORITHM = "RSA"; // The crypto algorithm to use
/**
* The endpoint to use for downloading the {@link PublicKey}.
*/
private static final String PUBLIC_KEY_ENDPOINT = "/crypto/pub";
/**
* The endpoint to check licenses at.
*/
private static final String CHECK_ENDPOINT = "http://localhost:7500/check";
private static final String CHECK_ENDPOINT = "/check";
/**
* The {@link Gson} instance to use.
*/
private static final Gson GSON = new GsonBuilder()
.serializeNulls()
.create();
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
/**
* The URL of the license server to make requests to.
*/
@NonNull private final String appUrl;
/**
* The product to use for client.
*/
@NonNull private final String product;
/**
* The {@link OkHttpClient} to use for requests.
*/
@NonNull private final OkHttpClient httpClient;
/**
* The {@link PublicKey} to use for encryption.
*/
@NonNull private final PublicKey publicKey;
public LicenseClient(@NonNull String appUrl, @NonNull String product, @NonNull File publicKeyFile) {
this.appUrl = appUrl;
this.product = product;
httpClient = new OkHttpClient(); // Create a new http client
publicKey = fetchPublicKey(publicKeyFile); // Fetch our public key
}
/**
* Read the public key from the given bytes.
*
* @param bytes the bytes of the public key
* @return the public key
* @see PrivateKey for public key
*/
@SneakyThrows
private static PublicKey readPublicKey(byte[] bytes) {
return KeyFactory.getInstance(ALGORITHM).generatePublic(new X509EncodedKeySpec(bytes));
}
/**
* Check the license with the given
* key for the given product.
*
* @param key the key to check
* @param product the product the key belongs to
* @param key the key to check
* @return the license response
* @see LicenseResponse for response
*/
@NonNull
public static LicenseResponse check(@NonNull String key, @NonNull String product) {
public LicenseResponse check(@NonNull String key) {
String hardwareId = getHardwareId(); // Get the hardware id of the machine
// Build the json body
Map<String, Object> body = new HashMap<>();
body.put("key", key);
body.put("key", encrypt(key));
body.put("product", product);
body.put("hwid", hardwareId);
body.put("hwid", encrypt(hardwareId));
String bodyJson = GSON.toJson(body); // The json body
OkHttpClient client = new OkHttpClient(); // Create a new http client
MediaType mediaType = MediaType.parse("application/json"); // Ensure the media type is json
RequestBody requestBody = RequestBody.create(bodyJson, mediaType); // Build the request body
RequestBody requestBody = RequestBody.create(mediaType, bodyJson); // Build the request body
Request request = new Request.Builder()
.url(CHECK_ENDPOINT)
.url(appUrl + CHECK_ENDPOINT)
.post(requestBody)
.build(); // Build the POST request
Response response = null; // The response of the request
int responseCode = -1; // The response code of the request
try { // Attempt to execute the request
response = client.newCall(request).execute();
response = httpClient.newCall(request).execute();
responseCode = response.code();
// If the response is successful, we can parse the response
@ -85,14 +141,25 @@ public final class LicenseExample {
JsonElement description = json.get("description");
JsonElement ownerSnowflake = json.get("ownerSnowflake");
JsonElement ownerName = json.get("ownerName");
JsonElement duration = json.get("duration");
JsonElement plan = json.get("plan");
JsonElement latestVersion = json.get("latestVersion");
// Parsing the expiration date if we have one
JsonElement expires = json.get("expires");
Date expiresDate = null;
if (!expires.isJsonNull()) {
OffsetDateTime offsetDateTime = OffsetDateTime.parse(expires.getAsString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
expiresDate = Date.from(offsetDateTime.toInstant());
}
// Return the license response
return new LicenseResponse(200, null,
description.isJsonNull() ? null : description.getAsString(),
ownerSnowflake.isJsonNull() ? -1 : ownerSnowflake.getAsLong(),
ownerName.isJsonNull() ? null : ownerName.getAsString(),
duration.isJsonNull() ? -1 : duration.getAsLong()
plan.getAsString(),
latestVersion.getAsString(),
expires.isJsonNull() ? null : expiresDate
);
} else {
ResponseBody errorBody = response.body(); // Get the error body
@ -117,6 +184,43 @@ public final class LicenseExample {
return new LicenseResponse(responseCode, "An unknown error occurred");
}
/**
* Fetch the public key.
* <p>
* If the public key is not already present, we
* fetch it from the server. Otherwise, the public
* key is loaded from the file.
* </p>
*
* @param publicKeyFile the public key file
* @return the public key
* @see PublicKey for public key
*/
@SneakyThrows
private PublicKey fetchPublicKey(@NonNull File publicKeyFile) {
byte[] publicKey;
if (publicKeyFile.exists()) { // Public key exists, use it
publicKey = Files.readAllBytes(publicKeyFile.toPath());
} else {
Request request = new Request.Builder()
.url(appUrl + PUBLIC_KEY_ENDPOINT)
.build(); // Build the GET request
@Cleanup Response response = httpClient.newCall(request).execute(); // Make the request
if (!response.isSuccessful()) { // Response wasn't successful
throw new IOException("Failed to download the public key, got response " + response.code());
}
ResponseBody body = response.body(); // Get the response body
assert body != null; // We need a response body
publicKey = body.bytes(); // Read our public key
// Write the response to the public key file
try (FileOutputStream outputStream = new FileOutputStream(publicKeyFile)) {
outputStream.write(publicKey);
}
}
return readPublicKey(publicKey);
}
/**
* Get the unique hardware
* identifier of this machine.
@ -124,7 +228,7 @@ public final class LicenseExample {
* @return the hardware id
*/
@NonNull
private static String getHardwareId() {
private String getHardwareId() {
SystemInfo systemInfo = new SystemInfo();
OperatingSystem operatingSystem = systemInfo.getOperatingSystem();
HardwareAbstractionLayer hardwareAbstractionLayer = systemInfo.getHardware();
@ -145,9 +249,25 @@ public final class LicenseExample {
+ String.format("%08x", processorIdentifier.hashCode()) + "-" + processors;
}
@AllArgsConstructor
@Getter
@ToString
/**
* Encrypt the given input.
*
* @param input the encrypted input
* @return the encrypted result
*/
@SneakyThrows @NonNull
private String encrypt(@NonNull String input) {
Cipher cipher = Cipher.getInstance(ALGORITHM); // Create our cipher
cipher.init(Cipher.ENCRYPT_MODE, publicKey); // Set our mode and public key
return Base64.getEncoder().encodeToString(cipher.doFinal(input.getBytes())); // Return our encrypted result
}
/**
* The response of a license check.
*
* @see #check(String)
*/
@AllArgsConstructor @Getter @ToString
public static class LicenseResponse {
/**
* The status code of the response.
@ -177,12 +297,19 @@ public final class LicenseExample {
private String ownerName;
/**
* The duration of the license, present if valid.
* <p>
* If -1, the license will be permanent.
* </p>
* The plan for this license.
*/
private long duration;
@NonNull private String plan;
/**
* The latest version of the product this license is for.
*/
@NonNull private String latestVersion;
/**
* The optional expiration {@link Date} of the license.
*/
private Date expires;
public LicenseResponse(long status, @NonNull String error) {
this.status = status;
@ -204,7 +331,7 @@ public final class LicenseExample {
* @return true if permanent, otherwise false
*/
public boolean isPermanent() {
return duration == -1;
return expires == null;
}
}
}

View File

@ -1,13 +1,19 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.example;
import java.util.concurrent.TimeUnit;
import java.io.File;
/**
* @author Braydon
*/
public final class Main {
public static void main(String[] args) {
LicenseExample.LicenseResponse response = LicenseExample.check("XXXX-XXXX-XXXX-XXXX", "Example");
LicenseClient client = new LicenseClient("http://localhost:7500", "Example", new File("public.key")); // Create the client
LicenseClient.LicenseResponse response = client.check("XXXX-XXXX-XXXX-XXXX"); // Check our license
if (!response.isValid()) { // License isn't valid
System.err.println("Invalid license: " + response.getError());
return;
@ -15,16 +21,15 @@ public final class Main {
// License is valid
System.out.println("License is valid!");
if (response.getOwnerName() != null) {
System.out.println("Welcome " + response.getOwnerName() + "!");
System.out.println("Welcome " + response.getOwnerName() + "! Your plan is " + response.getPlan() + " and the latest version is " + response.getLatestVersion());
}
if (response.getDescription() != null) {
System.out.println("Description: " + response.getDescription()); // License description
}
if (response.isPermanent()) { // License is permanent
System.out.println("Your license is permanent");
} else { // License has a duration
long durationSeconds = TimeUnit.SECONDS.toMillis(response.getDuration()); // The duration in seconds
System.out.println("Your license will expire in " + durationSeconds + " seconds");
} else { // License has an expiration date
System.out.printf("Your license will expire at: %s%n", response.getExpires().toInstant());
}
}
}

3
Frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

17
Frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
/node_modules
/.pnp
.pnp.js
/coverage
/.next/
/out/
/build
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.vercel
*.tsbuildinfo
next-env.d.ts
.directory

36
Frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

16
Frontend/components.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/libs"
}
}

4
Frontend/next.config.js Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

42
Frontend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "license-server",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@8.7.1",
"authors": [
{
"name": "Braydon",
"email": "braydonrainnny@gmail.com",
"url": "https://rainnny.club"
}
],
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
"next": "14.0.3",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.0.3",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

3372
Frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
Frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import "../globals.css";
import { Inter as FontSans } from "next/font/google";
import { cn } from "../libs/libs";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable
)}
>
{children}
</body>
</html>
);
}

View File

@ -0,0 +1,26 @@
import { Button } from "../components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../components/ui/context-menu";
const Home = (): JSX.Element => (
<div className="flex flex-col gap-5">
<h1>Hello World</h1>
<ContextMenu>
<ContextMenuTrigger>
<Button>Click Me</Button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Profile</ContextMenuItem>
<ContextMenuItem>Billing</ContextMenuItem>
<ContextMenuItem>Team</ContextMenuItem>
<ContextMenuItem>Subscription</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
);
export default Home;

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/libs"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/libs"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,76 @@
const { fontFamily } = require("tailwindcss/defaultTheme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./src/app/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
},
},
plugins: [require("tailwindcss-animate")],
};

29
Frontend/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/app/*"],
"@/components/*": ["./src/app/components/*"],
"@/libs": ["./src/app/libs/libs.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -1,3 +1,70 @@
# LicenseServer
[![Discord](https://discord.com/api/guilds/827863713855176755/widget.png)](https://discord.gg/p9gzFE2bc6)
![Wakatime Hours](https://wakatime.rainnny.club/api/badge/Rainnny/interval:any/project:LicenseServer)
[![Download](https://img.shields.io/badge/Download-Releases-darkgreen.svg)](https://git.rainnny.club/Rainnny/LicenseServer/releases)
A simple open-source licensing server for your products.
# LicenseServer
A simple open-source licensing server for your products.
## Discord Preview
![License Global Log](https://cdn.rainnny.club/SagsCD0I.png)
![License Owner Log](https://cdn.rainnny.club/JZdFxTCy.png)
![License Owner Lookup](https://cdn.rainnny.club/EU0g1iLZ.png)
## API Reference
### Check License
```http
POST /check
```
#### Body
| Key | Type | Description |
|:----------|:---------|:----------------------------------------------------------------|
| `key` | `string` | **Required**. Your base64 encrypted license key |
| `product` | `string` | **Required**. The product the license is for |
| `hwid` | `string` | **Required**. The base64 encrypted hardware id of the requester |
#### Response
##### Error
```json
{
"error": "Error message"
}
```
##### Success
```json
{
"description": "Testing",
"ownerSnowflake": 504147739131641857,
"ownerName": "Braydon#2712",
"expires": "2023-06-02T06:00:47.270+00:00"
}
```
## Deployment
### Docker
```bash
docker run -d -p 7500:7500 -v "$(pwd)/data/application.yml:/usr/local/app/application.yml" git.rainnny.club/rainnny/licenseserver:latest
```
### Docker Compose
```yml
version: '3'
services:
app:
image: git.rainnny.club/rainnny/licenseserver:latest
volumes:
- ./data/application.yml:/usr/local/app/application.yml
ports:
- "7500:7500"
```

View File

@ -3,6 +3,6 @@ services:
app:
image: git.rainnny.club/rainnny/licenseserver:latest
volumes:
- ./data:/usr/local/app
- ./data/application.yml:/usr/local/app/application.yml
ports:
- "7500:7500"

16
pom.xml
View File

@ -6,13 +6,13 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<version>3.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>me.braydon</groupId>
<artifactId>LicenseServer</artifactId>
<version>1.0.1</version>
<version>1.0.5</version>
<description>A simple open-source licensing server for your products.</description>
<properties>
@ -52,7 +52,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
@ -60,7 +60,7 @@
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>5.0.0-beta.9</version>
<version>5.2.1</version>
<exclusions>
<exclusion>
<groupId>club.minnced</groupId>
@ -92,6 +92,14 @@
<scope>compile</scope>
</dependency>
<!-- Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
<scope>compile</scope>
</dependency>
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>

6
renovate.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>Rainnny/renovate-config"
]
}

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license;
import com.google.gson.Gson;

View File

@ -0,0 +1,81 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.common;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import javax.crypto.Cipher;
import java.io.File;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* @author Braydon
*/
@UtilityClass
public final class CryptographyUtils {
private static final String ALGORITHM = "RSA"; // The algorithm to use
/**
* Generate a new key pair.
*
* @return the key pair
* @see KeyPair for key pair
*/
@NonNull @SneakyThrows
public static KeyPair generateKeyPair() {
KeyPairGenerator generator = KeyPairGenerator.getInstance(ALGORITHM); // Create a generator
generator.initialize(2048); // Set the key size
return generator.generateKeyPair(); // Return our generated key pair
}
/**
* Read the public key from the given file.
*
* @param keyFile the key file to read
* @return the public key
* @see PrivateKey for public key
*/
@SneakyThrows
public static PublicKey readPublicKey(@NonNull File keyFile) {
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(Files.readAllBytes(keyFile.toPath())); // Get the key spec
return KeyFactory.getInstance(ALGORITHM).generatePublic(keySpec); // Return the public key from the key spec
}
/**
* Read the private key from the given file.
*
* @param keyFile the key file to read
* @return the private key
* @see PrivateKey for private key
*/
@SneakyThrows
public static PrivateKey readPrivateKey(@NonNull File keyFile) {
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Files.readAllBytes(keyFile.toPath())); // Get the key spec
return KeyFactory.getInstance(ALGORITHM).generatePrivate(keySpec); // Return the private key from the key spec
}
/**
* Decrypt the given input with
* the provided private key.
*
* @param input the encrypted input
* @param privateKey the private key
* @return the decrypted result
* @see PrivateKey for private key
*/
@SneakyThrows @NonNull
public static String decryptMessage(@NonNull String input, @NonNull PrivateKey privateKey) {
Cipher cipher = Cipher.getInstance(ALGORITHM); // Create the cipher
cipher.init(Cipher.DECRYPT_MODE, privateKey); // Set our mode and private key
return new String(cipher.doFinal(Base64.getDecoder().decode(input))); // Return our decrypted result
}
}

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.common;
import jakarta.servlet.http.HttpServletRequest;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.common;
import lombok.NonNull;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.common;
import lombok.NonNull;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.common;
import lombok.NonNull;

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.controller;
import lombok.NonNull;
import me.braydon.license.model.License;
import me.braydon.license.service.CryptographyService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.security.PublicKey;
/**
* @author Braydon
*/
@RestController
@RequestMapping(value = "/crypto", produces = MediaType.APPLICATION_JSON_VALUE)
public final class CryptographyController {
/**
* The {@link CryptographyService} to use.
*/
@NonNull private final CryptographyService service;
@Autowired
public CryptographyController(@NonNull CryptographyService service) {
this.service = service;
}
/**
* Downloads the public key file.
*
* @return the response entity
* @see PublicKey for public key
* @see License for license
* @see ResponseEntity for response entity
*/
@GetMapping("/pub")
@ResponseBody
public ResponseEntity<Resource> publicKey() {
byte[] publicKey = service.getKeyPair().getPublic().getEncoded(); // Get the public key
String fileName = "public.key"; // The name of the file to download
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.contentLength(publicKey.length)
.body(new ByteArrayResource(publicKey));
}
}

View File

@ -1,14 +1,19 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.controller;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import me.braydon.license.LicenseServer;
import me.braydon.license.common.CryptographyUtils;
import me.braydon.license.common.IPUtils;
import me.braydon.license.dto.LicenseCheckBodyDTO;
import me.braydon.license.dto.LicenseDTO;
import me.braydon.license.exception.APIException;
import me.braydon.license.model.License;
import me.braydon.license.service.CryptographyService;
import me.braydon.license.service.LicenseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
@ -16,6 +21,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.PrivateKey;
import java.util.Map;
/**
@ -24,14 +30,20 @@ import java.util.Map;
@RestController
@RequestMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE)
public final class LicenseController {
/**
* The {@link CryptographyService} to use.
*/
@NonNull private final CryptographyService cryptographyService;
/**
* The {@link LicenseService} to use.
*/
@NonNull private final LicenseService service;
@NonNull private final LicenseService licenseService;
@Autowired
public LicenseController(@NonNull LicenseService service) {
this.service = service;
public LicenseController(@NonNull CryptographyService cryptographyService, @NonNull LicenseService licenseService) {
this.cryptographyService = cryptographyService;
this.licenseService = licenseService;
}
/**
@ -40,41 +52,58 @@ public final class LicenseController {
* @param body the body of the request
* @return the response entity
* @see License for license
* @see LicenseCheckBodyDTO for body
* @see ResponseEntity for response entity
*/
@PostMapping("/check")
@ResponseBody
public ResponseEntity<?> check(@NonNull HttpServletRequest request, @RequestBody @NonNull String body) {
public ResponseEntity<?> check(@NonNull HttpServletRequest request, @RequestBody @NonNull LicenseCheckBodyDTO body) {
try { // Attempt to check the license
String ip = IPUtils.getRealIp(request); // The IP of the requester
JsonObject jsonObject = LicenseServer.GSON.fromJson(body, JsonObject.class);
JsonElement key = jsonObject.get("key"); // Get the key
JsonElement product = jsonObject.get("product"); // Get the product
JsonElement hwid = jsonObject.get("hwid"); // Get the hwid
// Ensure the body keys aren't null
if (key.isJsonNull() || product.isJsonNull() || hwid.isJsonNull()) {
if (!body.isValid()) {
throw new APIException(HttpStatus.BAD_REQUEST, "Invalid request body");
}
// Ensure the IP is valid
String ip = IPUtils.getRealIp(request); // The IP of the requester
if (IPUtils.getIpType(ip) == -1) {
throw new APIException(HttpStatus.BAD_REQUEST, "Invalid IP address");
}
String key;
String hwid;
try {
PrivateKey privateKey = cryptographyService.getKeyPair().getPrivate(); // Get our private key
key = CryptographyUtils.decryptMessage(body.getKey(), privateKey); // Decrypt our license key
hwid = CryptographyUtils.decryptMessage(body.getHwid(), privateKey); // Decrypt our hwid
} catch (IllegalArgumentException ex) {
throw new APIException(HttpStatus.BAD_REQUEST, "Signature Error");
}
// Validating that the UUID is in the correct format
boolean invalidHwid = true;
if (hwid.contains("-")) {
int segments = hwid.substring(0, hwid.lastIndexOf("-")).split("-").length;
if (segments == 4) {
invalidHwid = false;
}
}
if (invalidHwid) { // Invalid HWID
throw new APIException(HttpStatus.BAD_REQUEST, "Invalid HWID");
}
// Check the license
License license = service.check(
key.getAsString(),
product.getAsString(),
License license = licenseService.check(
key,
body.getProduct(),
ip,
hwid.getAsString()
hwid
);
// Return OK with the license DTO
return ResponseEntity.ok(new LicenseDTO(
license.getDescription(),
license.getOwnerSnowflake(),
license.getOwnerName(),
license.getDuration()
license.getPlan(),
license.getLatestVersion(),
license.getExpires()
));
} catch (APIException ex) { // Handle the exception
return ResponseEntity.status(ex.getStatus())

View File

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
import me.braydon.license.model.License;
/**
* A data transfer object that contains
* the body for checking a {@link License}.
*
* @author Braydon
*/
@AllArgsConstructor @Getter @ToString
public class LicenseCheckBodyDTO {
/**
* The license key to check.
*/
private String key;
/**
* The product of the license to check.
*/
private String product;
/**
* The hardware id of the user checking the license.
*/
private String hwid;
/**
* Are these params valid?
*
* @return whether the params are valid
*/
public boolean isValid() {
return key != null && product != null && hwid != null;
}
}

View File

@ -1,10 +1,18 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import me.braydon.license.model.License;
import java.util.Date;
/**
* A data transfer object for a {@link License}.
*
@ -34,10 +42,17 @@ public class LicenseDTO {
private String ownerName;
/**
* The duration that this licensee is valid for.
* <p>
* If -1, the license will be permanent.
* </p>
* The plan for this license.
*/
private long duration;
@NonNull private String plan;
/**
* The latest version of the product this license is for.
*/
@NonNull private String latestVersion;
/**
* The optional expiration {@link Date} of this license.
*/
private Date expires;
}

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.exception;
import lombok.Getter;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.exception;
import me.braydon.license.model.License;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.exception;
import me.braydon.license.model.License;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.exception;
import me.braydon.license.model.License;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.exception;
import me.braydon.license.model.License;

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.model;
import lombok.Getter;
@ -9,6 +14,7 @@ import me.braydon.license.exception.LicenseHwidLimitExceededException;
import me.braydon.license.exception.LicenseIpLimitExceededException;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.util.Date;
import java.util.Set;
@ -44,6 +50,7 @@ public class License {
* If this is -1, the license is not owned by anyone.
* </p>
*/
@Field("owner.snowflake")
private long ownerSnowflake;
/**
@ -52,8 +59,19 @@ public class License {
* If this is null, the license is not owned by anyone.
* </p>
*/
@Field("owner.name")
private String ownerName;
/**
* The plan for this license.
*/
@NonNull private String plan;
/**
* The latest version of the product this license is for.
*/
@NonNull private String latestVersion;
/**
* The amount of uses this license has.
*/
@ -80,12 +98,9 @@ public class License {
private int hwidLimit;
/**
* The duration that this licensee is valid for.
* <p>
* If -1, the license will be permanent.
* </p>
* The optional expiration {@link Date} of this license.
*/
private long duration;
private Date expires;
/**
* The {@link Date} this license was last used.
@ -97,6 +112,18 @@ public class License {
*/
@NonNull private Date created;
/**
* Check if the Discord user
* with the given snowflake
* owns this license.
*
* @param snowflake the snowflake
* @return true if owns, otherwise false
*/
public boolean isOwner(long snowflake) {
return ownerSnowflake == snowflake;
}
/**
* Check if this license has expired.
* <p>
@ -113,7 +140,7 @@ public class License {
return false;
}
// Check if the license has expired
return System.currentTimeMillis() - created.getTime() >= duration;
return expires.before(new Date());
}
/**
@ -122,7 +149,7 @@ public class License {
* @return true if permanent, otherwise false
*/
public boolean isPermanent() {
return duration == -1L;
return expires == null;
}
/**

View File

@ -1,3 +1,8 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.repository;
import lombok.NonNull;

View File

@ -0,0 +1,63 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.service;
import lombok.Getter;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import me.braydon.license.common.CryptographyUtils;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.KeyPair;
import java.util.Base64;
/**
* @author Braydon
*/
@Service
@Slf4j(topic = "Cryptography")
@Getter
public final class CryptographyService {
/**
* Our {@link KeyPair}.
*/
@NonNull private final KeyPair keyPair;
@SneakyThrows
public CryptographyService() {
File publicKeyFile = new File("public.key"); // The private key
File privateKeyFile = new File("private.key"); // The private key
if (!publicKeyFile.exists() || !privateKeyFile.exists()) { // Missing private key, generate new key pair.
keyPair = CryptographyUtils.generateKeyPair(); // Generate new key pair
writeKey(keyPair.getPublic().getEncoded(), publicKeyFile); // Write our public key
writeKey(keyPair.getPrivate().getEncoded(), privateKeyFile); // Write our private key
log.info("New key pair has been generated");
log.info(Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()));
return;
}
// Load our private key from the file
keyPair = new KeyPair(CryptographyUtils.readPublicKey(publicKeyFile), CryptographyUtils.readPrivateKey(privateKeyFile));
log.info("Loaded private key from file " + privateKeyFile.getPath());
}
/**
* Write the given contents to the provided file.
*
* @param contents the content bytes to write
* @param file the file to write to
*/
private void writeKey(byte[] contents, @NonNull File file) {
try (FileOutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(contents);
} catch (IOException ex) {
ex.printStackTrace();
}
}
}

View File

@ -1,38 +1,82 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.service;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import jakarta.annotation.Nonnull;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import me.braydon.license.common.MiscUtils;
import me.braydon.license.common.TimeUtils;
import me.braydon.license.model.License;
import me.braydon.license.repository.LicenseRepository;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.emoji.Emoji;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.exceptions.ErrorResponseException;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.dv8tion.jda.api.requests.ErrorResponse;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.stereotype.Service;
import java.awt.*;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* @author Braydon
*/
@Service
@Slf4j(topic = "Discord")
public final class DiscordService {
private static final String CLEAR_IPS_BUTTON_ID = "clearIps";
private static final String CLEAR_HWIDS_BUTTON_ID = "clearHwids";
/**
* The {@link LicenseRepository} to use.
*/
@Nonnull private final LicenseRepository licenseRepository;
/**
* The version of this Springboot application.
*/
@NonNull private final String applicationVersion;
@NonNull private final String applicationVersion = "n/a";
/**
* The salt to use for hashing license keys.
*/
@Value("${salts.licenses}")
@NonNull private String licensesSalt;
/**
* The name of this Springboot application.
*/
@Value("${spring.application.name}")
@NonNull private String applicationName;
/**
* The token to the Discord bot.
*/
@ -69,14 +113,40 @@ public final class DiscordService {
@Value("${discord.logs.expired}") @Getter
private boolean logHwidLimitExceeded;
/**
* Should new IPs be sent to the license owner?
*/
@Value("${discord.owner-logs.newIp}") @Getter
private boolean logNewIpsToOwner;
/**
* Should new HWIDs be sent to the license owner?
*/
@Value("${discord.owner-logs.newHwid}") @Getter
private boolean logNewHwidsToOwner;
/**
* The {@link JDA} instance of the bot.
*/
private JDA jda;
/**
* Cached licenses for messages.
* <p>
* When a license is looked up by it's owner, the
* response message is cached (key is the message snowflake)
* for 5 minutes. This is so we're able to get the message
* an action was performed on, as well as action timeouts.
* </p>
*/
private final Cache<Long, License> cachedLicenses = CacheBuilder.newBuilder()
.expireAfterWrite(5L, TimeUnit.MINUTES)
.build();
@Autowired
public DiscordService(@NonNull BuildProperties buildProperties) {
this.applicationVersion = buildProperties.getVersion();
public DiscordService(@NonNull LicenseRepository licenseRepository/*, @NonNull BuildProperties buildProperties*/) {
this.licenseRepository = licenseRepository;
// this.applicationVersion = buildProperties.getVersion();
}
@PostConstruct @SneakyThrows
@ -87,20 +157,34 @@ public final class DiscordService {
return;
}
// Initialize the bot
long before = System.currentTimeMillis();
log.info("Logging in..."); // Log that we're logging in
jda = JDABuilder.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS
).setStatus(OnlineStatus.DO_NOT_DISTURB)
.setActivity(Activity.watching("your licenses"))
.build();
jda.awaitReady(); // Await JDA to be ready
// Log that we're logged in
log.info("Logged into {} in {}ms",
jda.getSelfUser().getAsTag(), System.currentTimeMillis() - before
);
new Thread(() -> {
long before = System.currentTimeMillis();
log.info("Logging in..."); // Log that we're logging in
jda = JDABuilder.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS
).setStatus(OnlineStatus.DO_NOT_DISTURB)
.setActivity(Activity.watching("your licenses"))
.addEventListeners(new EventHandler())
.build();
try {
jda.awaitReady(); // Await JDA to be ready
// Log that we're logged in
log.info("Logged into {} in {}ms",
jda.getSelfUser().getEffectiveName(), System.currentTimeMillis() - before
);
// Registering slash commands
jda.updateCommands().addCommands(
Commands.slash("license", "Manage one of your licenses")
.addOption(OptionType.STRING, "key", "The license key", true)
.addOption(OptionType.STRING, "product", "The product the license is for", true)
).queue();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}, "Discord Bot Thread").start();
}
/**
@ -125,9 +209,46 @@ public final class DiscordService {
throw new IllegalArgumentException("Log channel %s wasn't found".formatted(logsChannel));
}
// Send the log
textChannel.sendMessageEmbeds(embed.setFooter("%s v%s - %s".formatted(
applicationName, applicationVersion, TimeUtils.dateTime()
)).build()).queue();
textChannel.sendMessageEmbeds(buildEmbed(embed)).queue();
}
/**
* Send an embed to the owner
* of the given license.
*
* @param license the license
* @param embed the embed to send
* @see License for license
* @see EmbedBuilder for embed
*/
public void sendOwnerLog(@NonNull License license, @NonNull EmbedBuilder embed) {
// JDA must be ready to send logs
if (!isReady()) {
return;
}
// We need an owner for the license
if (license.getOwnerSnowflake() <= 0L) {
return;
}
// Lookup the owner of the license
jda.retrieveUserById(license.getOwnerSnowflake()).queue(owner -> {
if (owner == null) { // Couldn't locate the owner of the license
return;
}
owner.openPrivateChannel().queue(channel -> {
channel.sendMessageEmbeds(buildEmbed(embed)).queue(null, ex -> {
// Ignore the ex if the owner has priv msgs turned off, we don't care
if (((ErrorResponseException) ex).getErrorResponse() != ErrorResponse.CANNOT_SEND_TO_USER) {
ex.printStackTrace();
}
});
});
}, ex -> {
// Ignore the ex if the owner isn't found, we don't care
if (((ErrorResponseException) ex).getErrorResponse() != ErrorResponse.UNKNOWN_USER) {
ex.printStackTrace();
}
});
}
/**
@ -138,4 +259,145 @@ public final class DiscordService {
public boolean isReady() {
return jda != null && (jda.getStatus() == JDA.Status.CONNECTED);
}
/**
* Build the given embed.
*
* @param embedBuilder the embed builder
* @return the built embed
*/
@NonNull
private MessageEmbed buildEmbed(@NonNull EmbedBuilder embedBuilder) {
return embedBuilder.setFooter("%s v%s - %s".formatted(
applicationName, applicationVersion, TimeUtils.dateTime()
)).build();
}
/**
* The event handler for the bot.
*/
public class EventHandler extends ListenerAdapter {
@Override
public void onSlashCommandInteraction(@NonNull SlashCommandInteractionEvent event) {
// Bot isn't ready, don't handle events
if (!isReady()) {
return;
}
User user = event.getUser(); // The command executor
// Handle the license command
if (event.getName().equals("license")) {
String key = Objects.requireNonNull(event.getOption("key")).getAsString();
String product = Objects.requireNonNull(event.getOption("product")).getAsString();
event.deferReply(true).queue(); // Send thinking...
// License lookup
try {
Optional<License> optionalLicense = licenseRepository.getLicense(BCrypt.hashpw(key, licensesSalt), product);
if (optionalLicense.isEmpty() // License not found or owned by someone else
|| (!optionalLicense.get().isOwner(user.getIdLong()))) {
event.getHook().sendMessageEmbeds(buildEmbed(new EmbedBuilder()
.setColor(Color.RED)
.setTitle("License not found")
.setDescription("Could not locate the license you were looking for")
)).queue(); // Send the error message
return;
}
License license = optionalLicense.get(); // The found license
String obfuscateKey = MiscUtils.obfuscateKey(key); // Obfuscate the key
long expires = license.isPermanent() ? -1L : license.getExpires().getTime() / 1000L;
long lastUsed = license.getLastUsed() == null ? -1L : license.getLastUsed().getTime() / 1000L;
event.getHook().sendMessageEmbeds(buildEmbed(new EmbedBuilder()
.setColor(Color.BLUE)
.setTitle("Your License")
.addField("License", "`" + obfuscateKey + "`", true)
.addField("Product", license.getProduct(), true)
.addField("Description", license.getDescription(), true)
.addField("Expiration",
expires == -1L ? "Never" : "<t:" + expires + ":R>",
true
)
.addField("Plan", license.getPlan(), true)
.addField("Uses", String.valueOf(license.getUses()), true)
.addField("Last Used",
lastUsed == -1L ? "Never" : "<t:" + lastUsed + ":R>",
true
)
.addField("IPs",
license.getIps().size() + "/" + license.getIpLimit(),
true
)
.addField("HWIDs",
license.getHwids().size() + "/" + license.getHwidLimit(),
true
)
.addField("Created",
"<t:" + (license.getCreated().getTime() / 1000L) + ":R>",
true
)
)).addActionRow( // Buttons
Button.danger(CLEAR_IPS_BUTTON_ID, "Clear IPs")
.withEmoji(Emoji.fromUnicode("🗑️")),
Button.danger(CLEAR_HWIDS_BUTTON_ID, "Clear HWIDs")
.withEmoji(Emoji.fromUnicode("🗑️"))
).queue(message -> cachedLicenses.put(message.getIdLong(), license)); // Cache the license for the message
} catch (Exception ex) {
event.getHook().sendMessageEmbeds(buildEmbed(new EmbedBuilder()
.setColor(Color.RED)
.setTitle("Lookup Failed")
.setDescription("More information has been logged")
)).queue(); // Send the error message
ex.printStackTrace();
}
}
}
@Override
public void onButtonInteraction(@NonNull ButtonInteractionEvent event) {
// Bot isn't ready, don't handle events
if (!isReady()) {
return;
}
User user = event.getUser(); // The user who clicked the button
String componentId = event.getComponentId(); // The button id
// License Actions
boolean clearIps = componentId.equals(CLEAR_IPS_BUTTON_ID);
boolean clearHwids = componentId.equals(CLEAR_HWIDS_BUTTON_ID);
if (clearIps || clearHwids) {
event.deferReply(true).queue(); // Send thinking...
License license = cachedLicenses.getIfPresent(event.getMessageIdLong()); // Get the cached license
if (license == null || (!license.isOwner(user.getIdLong()))) { // License not found or owned by someone else
event.getHook().sendMessageEmbeds(buildEmbed(new EmbedBuilder()
.setColor(Color.RED)
.setTitle("License Action Failed")
.setDescription("The license couldn't be found or the action timed out")
)).queue(); // Send the error message
return;
}
try {
// Clear IPs
if (clearIps) {
license.setIps(new HashSet<>());
}
// Clear HWIDs
if (clearHwids) {
license.setHwids(new HashSet<>());
}
licenseRepository.save(license); // Save the license
event.getHook().sendMessageEmbeds(buildEmbed(new EmbedBuilder()
.setColor(Color.GREEN)
.setTitle("Cleared " + (clearIps ? "IP" : "HWID") + "s")
)).queue(); // Inform action success
} catch (Exception ex) {
event.getHook().sendMessageEmbeds(buildEmbed(new EmbedBuilder()
.setColor(Color.RED)
.setTitle("License Action Failed")
.setDescription("More information has been logged")
)).queue(); // Send the error message
ex.printStackTrace();
}
}
}
}
}

View File

@ -1,8 +1,15 @@
/*
* Copyright (c) 2023 Braydon (Rainnny). All rights reserved.
*
* For inquiries, please contact braydonrainnny@gmail.com
*/
package me.braydon.license.service;
import jakarta.annotation.PostConstruct;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import me.braydon.license.common.MiscUtils;
import me.braydon.license.common.RandomUtils;
import me.braydon.license.exception.*;
import me.braydon.license.model.License;
import me.braydon.license.repository.LicenseRepository;
@ -53,6 +60,29 @@ public final class LicenseService {
this.discordService = discordService;
}
/**
* Create a default license key
* when no other keys exist.
* TODO: Remove this in the future and replace with creation API route
*/
@PostConstruct
public void onInitialize() {
if (repository.count() == 0L) { // No license keys found, create default
String licenseKey = RandomUtils.generateLicenseKey(); // The license key
create(
licenseKey,
"Example",
"Example",
0L,
null,
1,
1,
null
);
log.info("Generated default license: {}", licenseKey);
}
}
/**
* Create a new license key.
*
@ -63,12 +93,12 @@ public final class LicenseService {
* @param ownerName the optional owner name of the license
* @param ipLimit the IP limit of the license
* @param hwidLimit the HWID limit of the license
* @param duration the duration of the license, -1 for permanent
* @param expires the optional expiration date of the license
* @return the created license
* @see License for license
*/
public License create(@NonNull String key, @NonNull String product, String description, long ownerSnowflake,
String ownerName, int ipLimit, int hwidLimit, long duration) {
String ownerName, int ipLimit, int hwidLimit, Date expires) {
// Create the new license
License license = new License();
license.setKey(BCrypt.hashpw(key, licensesSalt)); // Hash the key
@ -76,11 +106,13 @@ public final class LicenseService {
license.setDescription(description); // Use the given description, if any
license.setOwnerSnowflake(ownerSnowflake);
license.setOwnerName(ownerName);
license.setPlan("Basic");
license.setLatestVersion("1.0");
license.setIps(new HashSet<>());
license.setHwids(new HashSet<>());
license.setIpLimit(ipLimit); // Use the given IP limit
license.setHwidLimit(hwidLimit); // Use the given HWID limit
license.setDuration(duration);
license.setExpires(expires);
license.setCreated(new Date());
repository.insert(license); // Insert the newly created license
return license;
@ -107,12 +139,13 @@ public final class LicenseService {
}
License license = optionalLicense.get(); // The license found
String hashedIp = BCrypt.hashpw(ip, ipsSalt); // Hash the IP
String obfuscateKey = MiscUtils.obfuscateKey(key); // Obfuscate the key
boolean newIp = !license.getIps().contains(hashedIp); // Is the IP new?
boolean newHwid = !license.getHwids().contains(hwid); // Is the HWID new?
// Log the license being used, if enabled
if (discordService.isLogUses()) {
// god i hate sending discord embeds, it's so big and ugly :(
boolean newIp = !license.getIps().contains(hashedIp); // Is the IP new?
boolean newHwid = !license.getHwids().contains(hwid); // Is the HWID new?
// Constructing tags
StringBuilder tags = new StringBuilder();
@ -125,22 +158,15 @@ public final class LicenseService {
}
tags.append("HWID");
}
long expirationDate = (license.getCreated().getTime() + license.getDuration()) / 1000L;
long expires = license.isPermanent() ? -1L : license.getExpires().getTime() / 1000L;
int ipCount = license.getIps().size();
int hwidCount = license.getHwids().size();
discordService.sendLog(new EmbedBuilder()
.setColor(Color.BLUE)
.setTitle("License Used" + (!tags.isEmpty() ? " (" + tags + ")" : ""))
.addField("License",
"`" + MiscUtils.obfuscateKey(key) + "`",
true
)
.addField("Product",
license.getProduct(),
true
)
.addField("Description",
license.getDescription(),
true
)
.addField("License", "`" + obfuscateKey + "`", true)
.addField("Product", license.getProduct(), true)
.addField("Description", license.getDescription(), true)
.addField("Owner ID",
license.getOwnerSnowflake() <= 0L ? "N/A" : String.valueOf(license.getOwnerSnowflake()),
true
@ -149,24 +175,18 @@ public final class LicenseService {
license.getOwnerName() == null ? "N/A" : license.getOwnerName(),
true
)
.addField("Expires",
license.isPermanent() ? "Never" : "<t:" + expirationDate + ":R>",
.addField("Expiration",
expires == -1L ? "Never" : "<t:" + expires + ":R>",
true
)
.addField("IP",
ip,
true
)
.addField("HWID",
"```" + hwid + "```",
false
)
.addField("IP", ip, true)
.addField("HWID", "```" + hwid + "```", false)
.addField("IPs",
license.getIps().size() + "/" + license.getIpLimit(),
(newIp ? ipCount + 1 : ipCount) + "/" + license.getIpLimit(),
true
)
.addField("HWIDs",
license.getHwids().size() + "/" + license.getHwidLimit(),
(newHwid ? hwidCount + 1 : hwidCount) + "/" + license.getHwidLimit(),
true
)
);
@ -178,7 +198,7 @@ public final class LicenseService {
discordService.sendLog(new EmbedBuilder()
.setColor(Color.RED)
.setTitle("License Expired")
.setDescription("License `%s` is expired".formatted(MiscUtils.obfuscateKey(key)))
.setDescription("License `%s` is expired".formatted(obfuscateKey))
);
}
throw new LicenseExpiredException();
@ -186,6 +206,31 @@ public final class LicenseService {
try {
license.use(hashedIp, hwid); // Use the license
repository.save(license); // Save the used license
// Sending new IP log to the license owner
if (newIp && discordService.isLogNewIpsToOwner()) {
discordService.sendOwnerLog(license, new EmbedBuilder()
.setColor(0xF2781B)
.setTitle("New IP")
.setDescription("One of your licenses has been used on a new IP:")
.addField("License", "`" + obfuscateKey + "`", true)
.addField("Product", license.getProduct(), true)
.addField("IP", "```" + ip + "```", false)
);
}
// Sending new HWID log to the license owner
if (newHwid && discordService.isLogNewHwidsToOwner()) {
discordService.sendOwnerLog(license, new EmbedBuilder()
.setColor(0xF2781B)
.setTitle("New HWID")
.setDescription("One of your licenses has been used on a new HWID:")
.addField("License", "`" + obfuscateKey + "`", true)
.addField("Product", license.getProduct(), true)
.addField("HWID", "```" + hwid + "```", false)
);
}
// Logging the license use
log.info("License key '{}' for product '{}' was used by {} (HWID: {})", key, product, ip, hwid);
return license;
} catch (APIException ex) {
@ -195,7 +240,7 @@ public final class LicenseService {
.setColor(Color.RED)
.setTitle("License IP Limit Reached")
.setDescription("License `%s` has reached it's IP limit: **%s**".formatted(
MiscUtils.obfuscateKey(key),
obfuscateKey,
license.getIpLimit()
))
);
@ -204,7 +249,7 @@ public final class LicenseService {
.setColor(Color.RED)
.setTitle("License HWID Limit Reached")
.setDescription("License `%s` has reached it's HWID limit: **%s**".formatted(
MiscUtils.obfuscateKey(key),
obfuscateKey,
license.getHwidLimit()
))
);

View File

@ -12,6 +12,8 @@ salts:
# Discord Bot Configuration
discord:
token: ""
# Global Logs
logs:
channel: 0 # The channel ID to log to, leave as 0 to disable
uses: true # Should used licenses be logged?
@ -19,6 +21,11 @@ discord:
ipLimitExceeded: true # Should IP limited licenses be logged when used?
hwidLimitExceeded: true # Should HWID limited licenses be logged when used?
# License Owner Logs
owner-logs:
newIp: true # Should new IPs be sent to the license owner?
newHwid: true # Should new HWIDs be sent to the license owner?
# Log Configuration
logging:
file: