481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
LineageConnectionFactory,
|
|
validateLineageRequest,
|
|
dumpAndSendDB
|
|
} from "~/server/utils";
|
|
import { env } from "~/env/server";
|
|
import { TRPCError } from "@trpc/server";
|
|
import {
|
|
createTRPCRouter,
|
|
publicProcedure,
|
|
csrfProtectedProcedure
|
|
} from "~/server/api/utils";
|
|
import {
|
|
fetchWithTimeout,
|
|
checkResponse,
|
|
NetworkError,
|
|
TimeoutError,
|
|
APIError
|
|
} from "~/server/fetch-utils";
|
|
|
|
export const lineageDatabaseRouter = createTRPCRouter({
|
|
// Per-user DB credentials endpoint.
|
|
//
|
|
// Returns the caller's OWN remote Turso DB name + token so the client can
|
|
// open a direct libsql connection (game data syncs across devices). This is
|
|
// safe to expose to the authenticated user: the token grants full access only
|
|
// to that user's own DB, equivalent to an on-device SQLite that travels
|
|
// between devices. The generic p8-008 "exposing persistent DB tokens"
|
|
// concern does not apply — there is no cross-user escalation possible.
|
|
//
|
|
// REST surface: POST /api/lineage/database/creds (auth via Bearer header).
|
|
databaseCreds: csrfProtectedProcedure
|
|
.input(
|
|
z.object({
|
|
email: z.string().email(),
|
|
provider: z.enum(["email", "apple", "google"])
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { email, provider } = input;
|
|
|
|
// Bearer auth token comes from the Authorization header, exactly as the
|
|
// legacy REST endpoint read it. For `email` it's a Lineage JWT; for
|
|
// `apple` the apple_user_string; for `google` a Google id token.
|
|
const authHeader =
|
|
(ctx.event.nativeEvent.node?.req?.headers?.authorization as
|
|
| string
|
|
| undefined) ??
|
|
(ctx.event.request?.headers?.get("authorization") as
|
|
| string
|
|
| undefined);
|
|
const authToken = authHeader?.replace(/^Bearer\s+/i, "").trim();
|
|
if (!authToken) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Missing Authorization header"
|
|
});
|
|
}
|
|
|
|
const conn = LineageConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: `SELECT * FROM User WHERE email = ? AND provider = ? LIMIT 1`,
|
|
args: [email, provider]
|
|
});
|
|
|
|
if (res.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found"
|
|
});
|
|
}
|
|
|
|
const userRow = res.rows[0];
|
|
|
|
const valid = await validateLineageRequest({
|
|
auth_token: authToken,
|
|
userRow
|
|
});
|
|
|
|
if (!valid) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Invalid Verification"
|
|
});
|
|
}
|
|
|
|
// The legacy endpoint returned `{ db_name, db_token }`; the User row
|
|
// stores these as `database_name` / `database_token`.
|
|
return {
|
|
db_name: userRow.database_name,
|
|
db_token: userRow.database_token
|
|
};
|
|
}),
|
|
|
|
deletionInit: csrfProtectedProcedure
|
|
.input(
|
|
z.object({
|
|
email: z.string().email(),
|
|
db_name: z.string(),
|
|
db_token: z.string(),
|
|
authToken: z.string(),
|
|
skip_cron: z.boolean().optional(),
|
|
send_dump_target: z.string().email().optional()
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const {
|
|
email,
|
|
db_name,
|
|
db_token,
|
|
authToken,
|
|
skip_cron,
|
|
send_dump_target
|
|
} = input;
|
|
|
|
const conn = LineageConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: `SELECT * FROM User WHERE email = ?`,
|
|
args: [email]
|
|
});
|
|
const userRow = res.rows[0];
|
|
|
|
if (!userRow) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found"
|
|
});
|
|
}
|
|
|
|
const valid = await validateLineageRequest({
|
|
auth_token: authToken,
|
|
userRow
|
|
});
|
|
|
|
if (!valid) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Invalid Verification"
|
|
});
|
|
}
|
|
|
|
const { database_token, database_name } = userRow;
|
|
|
|
if (database_token !== db_token || database_name !== db_name) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Incorrect Verification"
|
|
});
|
|
}
|
|
|
|
if (skip_cron) {
|
|
if (send_dump_target) {
|
|
// Validate dump target matches the authenticated user's email (p8-005)
|
|
if (send_dump_target !== email) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Dump target must match account email"
|
|
});
|
|
}
|
|
|
|
const dumpRes = await dumpAndSendDB({
|
|
dbName: db_name,
|
|
dbToken: db_token,
|
|
sendTarget: send_dump_target
|
|
});
|
|
|
|
if (dumpRes.success) {
|
|
try {
|
|
const deleteRes = await fetchWithTimeout(
|
|
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
|
|
},
|
|
timeout: 20000 // 20s for database deletion
|
|
}
|
|
);
|
|
|
|
await checkResponse(deleteRes);
|
|
|
|
await conn.execute({
|
|
sql: `DELETE FROM User WHERE email = ?`,
|
|
args: [email]
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
message: `Account and Database deleted, db dump sent to email: ${send_dump_target}`
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof TimeoutError) {
|
|
console.error("Database deletion timeout:", error.message);
|
|
throw new TRPCError({
|
|
code: "TIMEOUT",
|
|
message:
|
|
"Database deletion timed out. Please contact support."
|
|
});
|
|
} else if (error instanceof NetworkError) {
|
|
console.error(
|
|
"Network error during database deletion:",
|
|
error.message
|
|
);
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Network error deleting database. Please try again."
|
|
});
|
|
} else if (error instanceof APIError) {
|
|
console.error(
|
|
"API error deleting database:",
|
|
error.status,
|
|
error.statusText
|
|
);
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to delete database"
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
} else {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: dumpRes.reason || "Failed to dump database"
|
|
});
|
|
}
|
|
} else {
|
|
try {
|
|
const deleteRes = await fetchWithTimeout(
|
|
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
|
|
},
|
|
timeout: 20000
|
|
}
|
|
);
|
|
|
|
await checkResponse(deleteRes);
|
|
|
|
await conn.execute({
|
|
sql: `DELETE FROM User WHERE email = ?`,
|
|
args: [email]
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
message: `Account and Database deleted`
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof TimeoutError) {
|
|
console.error("Database deletion timeout:", error.message);
|
|
throw new TRPCError({
|
|
code: "TIMEOUT",
|
|
message: "Database deletion timed out. Please contact support."
|
|
});
|
|
} else if (error instanceof NetworkError) {
|
|
console.error(
|
|
"Network error during database deletion:",
|
|
error.message
|
|
);
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Network error deleting database. Please try again."
|
|
});
|
|
} else if (error instanceof APIError) {
|
|
console.error(
|
|
"API error deleting database:",
|
|
error.status,
|
|
error.statusText
|
|
);
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to delete database"
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
} else {
|
|
const insertRes = await conn.execute({
|
|
sql: `INSERT INTO cron (email, db_name, db_token, send_dump_target) VALUES (?, ?, ?, ?)`,
|
|
args: [email, db_name, db_token, send_dump_target]
|
|
});
|
|
|
|
if (insertRes.rowsAffected > 0) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
message: `Deletion scheduled.`
|
|
};
|
|
} else {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: `Deletion not scheduled, due to server failure`
|
|
});
|
|
}
|
|
}
|
|
}),
|
|
|
|
deletionCheck: csrfProtectedProcedure
|
|
.input(z.object({ email: z.string().email() }))
|
|
.mutation(async ({ input }) => {
|
|
const { email } = input;
|
|
const conn = LineageConnectionFactory();
|
|
|
|
try {
|
|
const res = await conn.execute({
|
|
sql: `SELECT * FROM cron WHERE email = ?`,
|
|
args: [email]
|
|
});
|
|
const cronRow = res.rows[0];
|
|
|
|
if (!cronRow) {
|
|
return { status: 204, ok: true };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
created_at: cronRow.created_at as string
|
|
};
|
|
} catch (e) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to check deletion status"
|
|
});
|
|
}
|
|
}),
|
|
|
|
deletionCancel: csrfProtectedProcedure
|
|
.input(
|
|
z.object({
|
|
email: z.string().email(),
|
|
authToken: z.string()
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const { email, authToken } = input;
|
|
|
|
const conn = LineageConnectionFactory();
|
|
|
|
const resUser = await conn.execute({
|
|
sql: `SELECT * FROM User WHERE email = ?;`,
|
|
args: [email]
|
|
});
|
|
|
|
if (resUser.rows.length === 0) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found."
|
|
});
|
|
}
|
|
|
|
const userRow = resUser.rows[0];
|
|
|
|
const valid = await validateLineageRequest({
|
|
auth_token: authToken,
|
|
userRow
|
|
});
|
|
|
|
if (!valid) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Invalid credentials for cancelation."
|
|
});
|
|
}
|
|
|
|
const result = await conn.execute({
|
|
sql: `DELETE FROM cron WHERE email = ?;`,
|
|
args: [email]
|
|
});
|
|
|
|
if (result.rowsAffected > 0) {
|
|
return {
|
|
status: 200,
|
|
ok: true,
|
|
message: "Cron job(s) canceled successfully."
|
|
};
|
|
} else {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "No cron job found for the given email."
|
|
});
|
|
}
|
|
}),
|
|
|
|
deletionCron: publicProcedure.query(async () => {
|
|
const conn = LineageConnectionFactory();
|
|
const res = await conn.execute(
|
|
`SELECT * FROM cron WHERE created_at <= datetime('now', '-1 day');`
|
|
);
|
|
|
|
if (res.rows.length > 0) {
|
|
const executed_ids: (number | string)[] = [];
|
|
|
|
for (const row of res.rows) {
|
|
const { id, db_name, db_token, send_dump_target, email } = row;
|
|
|
|
if (send_dump_target) {
|
|
const dumpRes = await dumpAndSendDB({
|
|
dbName: db_name as string,
|
|
dbToken: db_token as string,
|
|
sendTarget: send_dump_target as string
|
|
});
|
|
|
|
if (dumpRes.success) {
|
|
try {
|
|
const deleteRes = await fetchWithTimeout(
|
|
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
|
|
},
|
|
timeout: 20000
|
|
}
|
|
);
|
|
|
|
await checkResponse(deleteRes);
|
|
|
|
await conn.execute({
|
|
sql: `DELETE FROM User WHERE email = ?`,
|
|
args: [email]
|
|
});
|
|
executed_ids.push(id as number);
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to delete database ${db_name} in cron job:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const deleteRes = await fetchWithTimeout(
|
|
`https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
|
|
},
|
|
timeout: 20000
|
|
}
|
|
);
|
|
|
|
await checkResponse(deleteRes);
|
|
|
|
await conn.execute({
|
|
sql: `DELETE FROM User WHERE email = ?`,
|
|
args: [email]
|
|
});
|
|
executed_ids.push(id as number);
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to delete database ${db_name} in cron job:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (executed_ids.length > 0) {
|
|
const placeholders = executed_ids.map(() => "?").join(", ");
|
|
const deleteQuery = `DELETE FROM cron WHERE id IN (${placeholders});`;
|
|
await conn.execute({ sql: deleteQuery, args: executed_ids });
|
|
|
|
return {
|
|
status: 200,
|
|
message:
|
|
"Processed databases deleted and corresponding cron rows removed."
|
|
};
|
|
}
|
|
}
|
|
|
|
return { status: 200, ok: true };
|
|
})
|
|
});
|