Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.schema
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# railway, fly.dev, heroku, vercel or any free service
RAILWAY = sinkaroid

# Enable or disable the GraphQL endpoint (/graphql). Set to true to enable.
LUSTPRESS_GRAPHQL = true

# default port
PORT = 3000

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM oven/bun:1.3.13-alpine AS base
FROM oven/bun:1.3.14-alpine AS base
WORKDIR /srv/app

# 1. Install production dependencies only
Expand Down
203 changes: 169 additions & 34 deletions README.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 9 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "lustpress",
"version": "8.4.0-alpha",
"description": "RESTful and experimental API for PH and other R18 websites",
"version": "8.5.0-alpha",
"description": "Unified REST + GraphQL API for PornHub and other R18 platforms",
"main": "src/index.ts",
"scripts": {
"start:dev": "bun --watch src/index.ts",
Expand All @@ -17,6 +17,8 @@
"test:youporn": "bun test -t youporn test/lustpress.test.ts",
"test:eporner": "bun test -t eporner test/lustpress.test.ts",
"test:txxx": "bun test -t txxx test/lustpress.test.ts",
"test:rest:all": "npm run test:pornhub && npm run test:xnxx && npm run test:redtube && npm run test:xvideos && npm run test:xhamster && npm run test:youporn && npm run test:eporner && npm run test:txxx",
"test:graphql": "bun test test/graphql.test.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
Expand All @@ -32,19 +34,20 @@
"@elysiajs/swagger": "^1.2.2",
"@keyv/redis": "^5.1.6",
"cheerio": "^1.2.0",
"elysia": "^1.2.24",
"elysia": "^1.4.29",
"graphql": "^17.0.2",
"keyv": "^5.6.0",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/bun": "^1.3.14",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"eslint": "^9.20.1",
"typescript": "^6.0.3"
},
"engines": {
"bun": ">=1.3.13"
"bun": ">=1.3.14"
},
"packageManager": "bun@1.3.13"
"packageManager": "bun@1.3.14"
}
52 changes: 52 additions & 0 deletions src/graphql/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { graphql } from "graphql";
import { Elysia } from "elysia";
import { schema } from "./schema";

export const graphqlPlugin = new Elysia()
.get("/graphql", async ({ query, set }) => {
const q = query as { query?: string; variables?: string };
if (!q.query) {
set.status = 400;
return { error: "Must provide query string" };
}
const result = await graphql({
schema,
source: q.query,
variableValues: q.variables ? JSON.parse(q.variables) : undefined,
});
return result;
}, {
detail: { summary: "Execute GraphQL query (GET)", tags: ["GraphQL"] },
})

// ── POST /graphql ────────────────────────────────
.post("/graphql", async ({ body, request, set }) => {
const contentType = request.headers.get("content-type") || "";

// Raw application/graphql
if (contentType.includes("application/graphql")) {
const text = await request.text();
const result = await graphql({ schema, source: text });
return result;
}

// JSON body
const json = body as {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
if (!json.query) {
set.status = 400;
return { error: "Must provide query string" };
}
const result = await graphql({
schema,
source: json.query,
variableValues: json.variables,
operationName: json.operationName,
});
return result;
}, {
detail: { summary: "Execute GraphQL query (POST)", tags: ["GraphQL"] },
});
234 changes: 234 additions & 0 deletions src/graphql/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import {
GraphQLBoolean,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
} from "graphql";

// ── Controller imports (individual files, following endpoint.ts pattern) ──

import { getPornhub } from "../controller/pornhub/pornhubGet";
import { searchPornhub } from "../controller/pornhub/pornhubSearch";
import { randomPornhub } from "../controller/pornhub/pornhubRandom";
import { relatedPornhub } from "../controller/pornhub/pornhubGetRelated";

import { getXnxx } from "../controller/xnxx/xnxxGet";
import { searchXnxx } from "../controller/xnxx/xnxxSearch";
import { randomXnxx } from "../controller/xnxx/xnxxRandom";
import { relatedXnxx } from "../controller/xnxx/xnxxGetRelated";

import { getRedtube } from "../controller/redtube/redtubeGet";
import { searchRedtube } from "../controller/redtube/redtubeSearch";
import { randomRedtube } from "../controller/redtube/redtubeRandom";
import { relatedRedtube } from "../controller/redtube/redtubeGetRelated";

import { getXvideos } from "../controller/xvideos/xvideosGet";
import { searchXvideos } from "../controller/xvideos/xvideosSearch";
import { randomXvideos } from "../controller/xvideos/xvideosRandom";
import { relatedXvideos } from "../controller/xvideos/xvideosGetRelated";

import { getXhamster } from "../controller/xhamster/xhamsterGet";
import { searchXhamster } from "../controller/xhamster/xhamsterSearch";
import { randomXhamster } from "../controller/xhamster/xhamsterRandom";
import { relatedXhamster } from "../controller/xhamster/xhamsterGetRelated";

import { getYouporn } from "../controller/youporn/youpornGet";
import { searchYouporn } from "../controller/youporn/youpornSearch";
import { randomYouporn } from "../controller/youporn/youpornRandom";
import { relatedYouporn } from "../controller/youporn/youpornGetRelated";

import { getEporner } from "../controller/eporner/epornerGet";
import { searchEporner } from "../controller/eporner/epornerSearch";
import { randomEporner } from "../controller/eporner/epornerRandom";
import { relatedEporner } from "../controller/eporner/epornerGetRelated";

import { getTxxx } from "../controller/txxx/txxxGet";
import { searchTxxx } from "../controller/txxx/txxxSearch";
import { randomTxxx } from "../controller/txxx/txxxRandom";
import { relatedTxxx } from "../controller/txxx/txxxGetRelated";


// ── Shared GraphQL types ──

const VideoDataType = new GraphQLObjectType({
name: "VideoData",
fields: {
title: { type: GraphQLString },
id: { type: GraphQLString },
image: { type: GraphQLString },
duration: { type: GraphQLString },
views: { type: GraphQLString },
rating: { type: GraphQLString },
uploaded: { type: GraphQLString },
upvoted: { type: GraphQLString },
downvoted: { type: GraphQLString },
channel: { type: GraphQLString },
models: { type: new GraphQLList(GraphQLString) },
tags: { type: new GraphQLList(GraphQLString) },
},
});

const VideoResultType = new GraphQLObjectType({
name: "VideoResult",
fields: {
success: { type: GraphQLBoolean },
data: { type: VideoDataType },
source: { type: GraphQLString },
assets: { type: new GraphQLList(GraphQLString) },
},
});

const SearchItemType = new GraphQLObjectType({
name: "SearchItem",
fields: {
link: { type: GraphQLString },
id: { type: GraphQLString },
title: { type: GraphQLString },
image: { type: GraphQLString },
duration: { type: GraphQLString },
rating: { type: GraphQLString },
views: { type: GraphQLString },
uploader: { type: GraphQLString },
video: { type: GraphQLString },
},
});

const SearchResultType = new GraphQLObjectType({
name: "SearchResult",
fields: {
success: { type: GraphQLBoolean },
data: { type: new GraphQLList(SearchItemType) },
source: { type: GraphQLString },
},
});

// ── Shared function type aliases (kept separate so eslint doesn't scan type-only params) ──

// eslint-disable-next-line no-unused-vars
type SourceGetFn = (p: { query: { id: string } }) => Promise<unknown>;
// eslint-disable-next-line no-unused-vars
type SourceSearchFn = (p: { query: { key: string; page?: string } }) => Promise<unknown>;
type SourceRandomFn = () => Promise<unknown>;

// ── Helper: build per-source query type ──

function makeSourceType(name: string, fns: {
get?: SourceGetFn;
search?: SourceSearchFn;
random?: SourceRandomFn;
related?: SourceGetFn;
}) {
const fields: Record<string, any> = {};
if (fns.get) {
fields.get = {
type: VideoResultType,
args: { id: { type: GraphQLString } },
resolve: async (_: unknown, a: { id: string }) =>
fns.get!({ query: { id: a.id } }),
};
}
if (fns.search) {
fields.search = {
type: SearchResultType,
args: {
key: { type: new GraphQLNonNull(GraphQLString) },
page: { type: GraphQLString },
},
resolve: async (_: unknown, a: { key: string; page?: string }) =>
fns.search!({ query: { key: a.key, page: a.page } }),
};
}
if (fns.random) {
fields.random = {
type: VideoResultType,
resolve: async () => fns.random!(),
};
}
if (fns.related) {
fields.related = {
type: SearchResultType,
args: { id: { type: GraphQLString } },
resolve: async (_: unknown, a: { id: string }) =>
fns.related!({ query: { id: a.id } }),
};
}
return new GraphQLObjectType({ name: `${name}Queries`, fields });
}

// ── Namespaced source query types ──

const PornhubQueriesType = makeSourceType("Pornhub", {
get: getPornhub,
search: searchPornhub,
random: randomPornhub,
related: relatedPornhub,
});

const XnxxQueriesType = makeSourceType("Xnxx", {
get: getXnxx,
search: searchXnxx,
random: randomXnxx,
related: relatedXnxx,
});

const RedtubeQueriesType = makeSourceType("Redtube", {
get: getRedtube,
search: searchRedtube,
random: randomRedtube,
related: relatedRedtube,
});

const XvideosQueriesType = makeSourceType("Xvideos", {
get: getXvideos,
search: searchXvideos,
random: randomXvideos,
related: relatedXvideos,
});

const XhamsterQueriesType = makeSourceType("Xhamster", {
get: getXhamster,
search: searchXhamster,
random: randomXhamster,
related: relatedXhamster,
});

const YoupornQueriesType = makeSourceType("Youporn", {
get: getYouporn,
search: searchYouporn,
random: randomYouporn,
related: relatedYouporn,
});

const EpornerQueriesType = makeSourceType("Eporner", {
get: getEporner,
search: searchEporner,
random: randomEporner,
related: relatedEporner,
});

const TxxxQueriesType = makeSourceType("Txxx", {
get: getTxxx,
search: searchTxxx,
random: randomTxxx,
related: relatedTxxx,
});

// ── Root Query type ──

const QueryType = new GraphQLObjectType({
name: "Query",
fields: {
pornhub: { type: PornhubQueriesType, resolve: () => ({}) },
xnxx: { type: XnxxQueriesType, resolve: () => ({}) },
redtube: { type: RedtubeQueriesType, resolve: () => ({}) },
xvideos: { type: XvideosQueriesType, resolve: () => ({}) },
xhamster: { type: XhamsterQueriesType, resolve: () => ({}) },
youporn: { type: YoupornQueriesType, resolve: () => ({}) },
eporner: { type: EpornerQueriesType, resolve: () => ({}) },
txxx: { type: TxxxQueriesType, resolve: () => ({}) },
},
});

export const schema = new GraphQLSchema({ query: QueryType });
Loading
Loading