mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 22:09:16 +01:00
feat: Add Meilisearch integration, begin work on search endpoint
This commit is contained in:
parent
d9f428eed6
commit
aa0813fef8
14 changed files with 605 additions and 7 deletions
|
|
@ -1,8 +1,13 @@
|
|||
import { applyConfig } from "@api";
|
||||
import { getConfig } from "@config";
|
||||
import { parseRequest } from "@request";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { sanitizeHtml } from "@sanitization";
|
||||
import type { MatchedRoute } from "bun";
|
||||
import { parse } from "marked";
|
||||
import { client } from "~database/datasource";
|
||||
import {
|
||||
editStatus,
|
||||
isViewableByUser,
|
||||
statusAndUserRelations,
|
||||
statusToAPI,
|
||||
|
|
@ -11,7 +16,7 @@ import { getFromRequest } from "~database/entities/User";
|
|||
import type { APIRouteMeta } from "~types/api";
|
||||
|
||||
export const meta: APIRouteMeta = applyConfig({
|
||||
allowedMethods: ["GET", "DELETE"],
|
||||
allowedMethods: ["GET", "DELETE", "PUT"],
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
|
|
@ -19,7 +24,7 @@ export const meta: APIRouteMeta = applyConfig({
|
|||
route: "/api/v1/statuses/:id",
|
||||
auth: {
|
||||
required: false,
|
||||
requiredOnMethods: ["DELETE"],
|
||||
requiredOnMethods: ["DELETE", "PUT"],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -39,6 +44,8 @@ export default async (
|
|||
include: statusAndUserRelations,
|
||||
});
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Check if user is authorized to view this status (if it's private)
|
||||
if (!status || !isViewableByUser(status, user))
|
||||
return errorResponse("Record not found", 404);
|
||||
|
|
@ -69,6 +76,150 @@ export default async (
|
|||
},
|
||||
200
|
||||
);
|
||||
} else if (req.method == "PUT") {
|
||||
if (status.authorId !== user?.id) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const {
|
||||
status: statusText,
|
||||
content_type,
|
||||
"poll[expires_in]": expires_in,
|
||||
"poll[options][]": options,
|
||||
"media_ids[]": media_ids,
|
||||
spoiler_text,
|
||||
sensitive,
|
||||
} = await parseRequest<{
|
||||
status?: string;
|
||||
spoiler_text?: string;
|
||||
sensitive?: boolean;
|
||||
language?: string;
|
||||
content_type?: string;
|
||||
"media_ids[]"?: string[];
|
||||
"poll[options][]"?: string[];
|
||||
"poll[expires_in]"?: number;
|
||||
"poll[multiple]"?: boolean;
|
||||
"poll[hide_totals]"?: boolean;
|
||||
}>(req);
|
||||
|
||||
// TODO: Add Poll support
|
||||
// Validate status
|
||||
if (!statusText && !(media_ids && media_ids.length > 0)) {
|
||||
return errorResponse(
|
||||
"Status is required unless media is attached",
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
// Validate media_ids
|
||||
if (media_ids && !Array.isArray(media_ids)) {
|
||||
return errorResponse("Media IDs must be an array", 422);
|
||||
}
|
||||
|
||||
// Validate poll options
|
||||
if (options && !Array.isArray(options)) {
|
||||
return errorResponse("Poll options must be an array", 422);
|
||||
}
|
||||
|
||||
if (options && options.length > 4) {
|
||||
return errorResponse("Poll options must be less than 5", 422);
|
||||
}
|
||||
|
||||
if (media_ids && media_ids.length > 0) {
|
||||
// Disallow poll
|
||||
if (options) {
|
||||
return errorResponse("Cannot attach poll to media", 422);
|
||||
}
|
||||
if (media_ids.length > 4) {
|
||||
return errorResponse("Media IDs must be less than 5", 422);
|
||||
}
|
||||
}
|
||||
|
||||
if (options && options.length > config.validation.max_poll_options) {
|
||||
return errorResponse(
|
||||
`Poll options must be less than ${config.validation.max_poll_options}`,
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
options &&
|
||||
options.some(
|
||||
option => option.length > config.validation.max_poll_option_size
|
||||
)
|
||||
) {
|
||||
return errorResponse(
|
||||
`Poll options must be less than ${config.validation.max_poll_option_size} characters`,
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
if (expires_in && expires_in < config.validation.min_poll_duration) {
|
||||
return errorResponse(
|
||||
`Poll duration must be greater than ${config.validation.min_poll_duration} seconds`,
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
if (expires_in && expires_in > config.validation.max_poll_duration) {
|
||||
return errorResponse(
|
||||
`Poll duration must be less than ${config.validation.max_poll_duration} seconds`,
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
let sanitizedStatus: string;
|
||||
|
||||
if (content_type === "text/markdown") {
|
||||
sanitizedStatus = await sanitizeHtml(parse(statusText ?? ""));
|
||||
} else if (content_type === "text/x.misskeymarkdown") {
|
||||
// Parse as MFM
|
||||
// TODO: Parse as MFM
|
||||
sanitizedStatus = await sanitizeHtml(parse(statusText ?? ""));
|
||||
} else {
|
||||
sanitizedStatus = await sanitizeHtml(statusText ?? "");
|
||||
}
|
||||
|
||||
if (sanitizedStatus.length > config.validation.max_note_size) {
|
||||
return errorResponse(
|
||||
`Status must be less than ${config.validation.max_note_size} characters`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Check if status body doesnt match filters
|
||||
if (
|
||||
config.filters.note_filters.some(
|
||||
filter => statusText?.match(filter)
|
||||
)
|
||||
) {
|
||||
return errorResponse("Status contains blocked words", 422);
|
||||
}
|
||||
|
||||
// Check if media attachments are all valid
|
||||
|
||||
const foundAttachments = await client.attachment.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: media_ids ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (foundAttachments.length !== (media_ids ?? []).length) {
|
||||
return errorResponse("Invalid media IDs", 422);
|
||||
}
|
||||
|
||||
// Update status
|
||||
const newStatus = await editStatus(status, {
|
||||
content: sanitizedStatus,
|
||||
content_type,
|
||||
media_attachments: media_ids,
|
||||
spoiler_text: spoiler_text ?? "",
|
||||
sensitive: sensitive ?? false,
|
||||
});
|
||||
|
||||
return jsonResponse(await statusToAPI(newStatus, user));
|
||||
}
|
||||
|
||||
return jsonResponse({});
|
||||
|
|
|
|||
49
server/api/api/v1/statuses/[id]/source.ts
Normal file
49
server/api/api/v1/statuses/[id]/source.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { applyConfig } from "@api";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import type { MatchedRoute } from "bun";
|
||||
import { client } from "~database/datasource";
|
||||
import { createLike } from "~database/entities/Like";
|
||||
import {
|
||||
isViewableByUser,
|
||||
statusAndUserRelations,
|
||||
statusToAPI,
|
||||
} from "~database/entities/Status";
|
||||
import { getFromRequest } from "~database/entities/User";
|
||||
import type { APIRouteMeta } from "~types/api";
|
||||
import type { APIStatus } from "~types/entities/status";
|
||||
|
||||
export const meta: APIRouteMeta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/statuses/:id/source",
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Favourite a post
|
||||
*/
|
||||
export default async (
|
||||
req: Request,
|
||||
matchedRoute: MatchedRoute
|
||||
): Promise<Response> => {
|
||||
const id = matchedRoute.params.id;
|
||||
|
||||
const { user } = await getFromRequest(req);
|
||||
|
||||
if (!user) return errorResponse("Unauthorized", 401);
|
||||
|
||||
const status = await client.status.findUnique({
|
||||
where: { id },
|
||||
include: statusAndUserRelations,
|
||||
});
|
||||
|
||||
// Check if user is authorized to view this status (if it's private)
|
||||
if (!status || !isViewableByUser(status, user))
|
||||
return errorResponse("Record not found", 404);
|
||||
};
|
||||
60
server/api/api/v2/search/index.ts
Normal file
60
server/api/api/v2/search/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { applyConfig } from "@api";
|
||||
import { parseRequest } from "@request";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { getFromRequest } from "~database/entities/User";
|
||||
import type { APIRouteMeta } from "~types/api";
|
||||
|
||||
export const meta: APIRouteMeta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 10,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v2/search",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: ["read:search"],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload new media
|
||||
*/
|
||||
export default async (req: Request): Promise<Response> => {
|
||||
const { user } = await getFromRequest(req);
|
||||
|
||||
const {
|
||||
q,
|
||||
type,
|
||||
resolve,
|
||||
following,
|
||||
account_id,
|
||||
max_id,
|
||||
min_id,
|
||||
limit,
|
||||
offset,
|
||||
} = await parseRequest<{
|
||||
q?: string;
|
||||
type?: string;
|
||||
resolve?: boolean;
|
||||
following?: boolean;
|
||||
account_id?: string;
|
||||
max_id?: string;
|
||||
min_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}>(req);
|
||||
|
||||
if (!user && (resolve || offset)) {
|
||||
return errorResponse(
|
||||
"Cannot use resolve or offset without being authenticated",
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
accounts: [],
|
||||
statuses: [],
|
||||
hashtags: [],
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue