Compare commits

...
14 changed files with 40 additions and 52 deletions
+8 -19
View File
@@ -12923,25 +12923,14 @@
]
},
"AdminReportListResponse": {
"oneOf": [
{
"type": "object",
"properties": {
"reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}},
"total": {"type": "number"},
"offset": {"type": "number"},
"limit": {"type": "number"}
},
"required": ["reports", "total", "offset", "limit"]
},
{
"type": "object",
"properties": {
"reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}}
},
"required": ["reports"]
}
]
"type": "object",
"properties": {
"reports": {"type": "array", "items": {"$ref": "#/components/schemas/ReportAdminResponseSchema"}},
"total": {"type": "number"},
"offset": {"type": "number"},
"limit": {"type": "number"}
},
"required": ["reports", "total", "offset", "limit"]
},
"ReportAdminResponseSchema": {
"type": "object",
@@ -74,7 +74,7 @@ export class AdminReportService {
const {reportService} = this.deps;
const requestedLimit = limit || 50;
const currentOffset = offset || 0;
const reports = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
const {reports, total} = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
const requestCache = createRequestCache();
const reportNsfwLookupCache = createReportNsfwLookupCache();
const reportResponses = await Promise.all(
@@ -84,6 +84,9 @@ export class AdminReportService {
);
return {
reports: reportResponses,
total,
offset: currentOffset,
limit: requestedLimit,
};
}
@@ -108,7 +108,7 @@ export class AvatarService {
type: 'base64',
base64: base64Data,
version: 2,
nsfw: 'block',
nsfw: 'allow',
}),
kind,
errorPath,
@@ -163,7 +163,7 @@ export class AvatarService {
type: 'base64',
base64: base64Data,
version: 2,
nsfw: 'block',
nsfw: 'allow',
}),
kind: 'avatar',
errorPath,
@@ -403,7 +403,7 @@ export class EntityAssetService {
type: 'base64',
base64: base64Data,
version: 2,
nsfw: 'block',
nsfw: 'allow',
}),
kind,
errorPath,
+9 -4
View File
@@ -813,14 +813,19 @@ export class ReportService {
return reports.filter((report): report is IARSubmission => report !== null);
}
async listReportsByStatus(status: number, limit?: number, offset?: number): Promise<Array<IARSubmission>> {
async listReportsByStatus(
status: number,
limit?: number,
offset?: number,
): Promise<{reports: Array<IARSubmission>; total: number}> {
if (!this.reportSearchService) {
throw new FeatureTemporarilyDisabledError();
}
const {hits} = await this.reportSearchService.listReportsByStatus(status, limit, offset);
const {hits, total} = await this.reportSearchService.listReportsByStatus(status, limit, offset);
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
const reports = await Promise.all(reportIds.map((id) => this.reportRepository.getReport(id)));
return reports.filter((report): report is IARSubmission => report !== null);
const loaded = await Promise.all(reportIds.map((id) => this.reportRepository.getReport(id)));
const reports = loaded.filter((report): report is IARSubmission => report !== null);
return {reports, total: Math.max(0, total - (hits.length - reports.length))};
}
async resolveReport(
+1 -1
View File
@@ -586,7 +586,7 @@ export class WebhookService {
type: 'external',
url: avatarUrl,
with_base64: true,
nsfw: 'block',
nsfw: 'allow',
});
if (!metadata?.base64) {
await this.cacheService.set(cacheKey, WEBHOOK_AVATAR_MISSING_CACHE_VALUE, seconds('5 minutes'));
+1 -1
View File
@@ -22,7 +22,7 @@ COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm --filter fluxer_docs run build
RUN pnpm deploy --legacy --filter=fluxer_docs --prod /out
RUN pnpm deploy --legacy --filter=fluxer_docs --prod --config.allowUnusedPatches=true /out
FROM node:24-bookworm-slim
+1 -1
View File
@@ -344,7 +344,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['http-api/unfurl.mdx', {'table-cell': 2, 'table-parallel': 1}],
['http-api/users.mdx', {'table-fit': 1, 'table-identifier': 4}],
['http-api/users/content.mdx', {'table-cell': 4}],
['http-api/users/current-user.mdx', {'table-cell': 1, 'table-identifier': 1}],
['http-api/users/current-user.mdx', {'table-identifier': 1}],
['http-api/users/data-harvest.mdx', {'table-cell': 2}],
['http-api/users/email-and-password.mdx', {'table-identifier': 1}],
['http-api/users/mfa.mdx', {'table-cell': 3, 'table-parallel': 1}],
@@ -455,7 +455,6 @@ Fluxer checks the decoded bytes of `avatar` and `banner` against the instance's
| 400 | [error response](/http-api/#error-response) | No discriminator can be allocated for the requested username (`USERNAME_NOT_AVAILABLE`) |
| 400 | [error response](/http-api/#error-response) | The discriminator differs from the current one |
| 400 | [error response](/http-api/#error-response) | The image is malformed, oversized, or an unsupported format |
| 400 | [error response](/http-api/#error-response) | The image is marked explicit (`EXPLICIT_CONTENT_CANNOT_BE_SENT`) |
| 400 | [error response](/http-api/#error-response) | The tag change allowance is exhausted |
| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
| 403 | [error response](/http-api/#error-response) | The username or biography is blocked (`CONTENT_BLOCKED`) |
@@ -502,7 +502,7 @@ Fluxer decides the single community refusal first, so it precedes the email, bot
A `name` that satisfies the 1 to 100 character bound only before normalisation is rejected with the field code `STRING_LENGTH_INVALID`. The normalised name is scanned against the instance phrase and URL blocklists, and a match returns 403 `CONTENT_BLOCKED`.
The `icon` base64 payload is bounded to 1 to 13981016 characters and is otherwise rejected with `BASE64_LENGTH_INVALID`, and a payload that is not valid base64 with `INVALID_BASE64_FORMAT`. The decoded image must fit within the instance `avatar_max_size` limit, which defaults to the 10 MiB ceiling, and a larger image is rejected with `IMAGE_SIZE_EXCEEDS_LIMIT`. PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, and SVG are accepted. An animated AVIF is rejected with `INVALID_IMAGE_FORMAT`. An image the media classifier marks as explicit is rejected with 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT`.
The `icon` base64 payload is bounded to 1 to 13981016 characters and is otherwise rejected with `BASE64_LENGTH_INVALID`, and a payload that is not valid base64 with `INVALID_BASE64_FORMAT`. The decoded image must fit within the instance `avatar_max_size` limit, which defaults to the 10 MiB ceiling, and a larger image is rejected with `IMAGE_SIZE_EXCEEDS_LIMIT`. PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, and SVG are accepted. An animated AVIF is rejected with `INVALID_IMAGE_FORMAT`.
Without a template the guild is created with a `Text Channels` category holding a text channel named `general`, a `Voice Channels` category holding a voice channel named `General`, and an everyone role with the default permission set. The `general` channel becomes the system channel.
@@ -518,7 +518,7 @@ The creation response has none of them. Read [Get guild](#get-guild) afterwards
| 400<sup>1</sup> | [error response](/http-api/#error-response) | Body, image, template, bot or unclaimed credential, configured guild limit, or single community policy rejects creation |
| 403<sup>2</sup> | [error response](/http-api/#error-response) | Email address is unverified, or the name is blocked |
<sup>1</sup> The [error code](/http-api/errors/) is `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS` while the single community policy is active, `BOTS_CANNOT_CREATE_GUILDS` for a bot credential, `UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS` for an unclaimed account, `MAX_GUILDS` at the configured guild limit, `GUILD_TEMPLATE_INVALID` for a rejected template, `EXPLICIT_CONTENT_CANNOT_BE_SENT` for an explicit icon, and `INVALID_FORM_BODY` otherwise
<sup>1</sup> The [error code](/http-api/errors/) is `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS` while the single community policy is active, `BOTS_CANNOT_CREATE_GUILDS` for a bot credential, `UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS` for an unclaimed account, `MAX_GUILDS` at the configured guild limit, `GUILD_TEMPLATE_INVALID` for a rejected template, and `INVALID_FORM_BODY` otherwise
<sup>2</sup> The [error code](/http-api/errors/) is `GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED` for an unverified email address and `CONTENT_BLOCKED` for a blocked name or body string
@@ -652,7 +652,7 @@ Every field is optional. An omitted field preserves its current value, and a fie
<sup>1</sup> The value is normalised and trimmed before its length is measured, and the normalised name is scanned against the instance phrase and URL blocklists, so a match returns 403 `CONTENT_BLOCKED`
<sup>2</sup> The accepted encoding, byte ceiling, and format set are the ones listed by [Create guild](#create-guild), and an image the media classifier marks as explicit is rejected with 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT`. No guild feature gates an animated icon on write, but the `a_` prefix is stripped from the returned hash while the guild lacks `ANIMATED_ICON`
<sup>2</sup> The accepted encoding, byte ceiling, and format set are the ones listed by [Create guild](#create-guild). No guild feature gates an animated icon on write, but the `a_` prefix is stripped from the returned hash while the guild lacks `ANIMATED_ICON`
<sup>3</sup> The channel must exist in this guild and be a text channel, and is otherwise rejected with `SYSTEM_CHANNEL_MUST_BE_IN_GUILD` or `SYSTEM_CHANNEL_MUST_BE_TEXT`
@@ -694,7 +694,7 @@ Send the current array with the intended changes applied. A feature without the
| 403<sup>2</sup> | [error response](/http-api/#error-response) | Guild is unavailable, the name is blocked, `MANAGE_GUILD` or ownership is absent, or sudo mode is required |
| 404<sup>3</sup> | [error response](/http-api/#error-response) | Guild does not exist |
<sup>1</sup> The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED` for a caller who holds `MANAGE_GUILD` but cannot exercise it, `EXPLICIT_CONTENT_CANNOT_BE_SENT` for an explicit image, and `INVALID_FORM_BODY` otherwise
<sup>1</sup> The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED` for a caller who holds `MANAGE_GUILD` but cannot exercise it, and `INVALID_FORM_BODY` otherwise
<sup>2</sup> The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked name or body string, `SUDO_MODE_REQUIRED` when an MFA level change is unproven, and `MISSING_PERMISSIONS` for a non-member, a missing `MANAGE_GUILD`, or a non-owner changing the MFA level
@@ -139,7 +139,7 @@ An account is unclaimed while it holds no password credential, is not a bot, and
Decoded avatar and banner content stays within the resolved `avatar_max_size` limit, which defaults to 10485760 bytes, and a larger payload is rejected with `IMAGE_SIZE_EXCEEDS_LIMIT`. Accepted formats are PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JPEG XL, and SVG. Anything else, animated AVIF included, is rejected with `INVALID_IMAGE_FORMAT`, and so is content the Media Proxy cannot identify.
Content the explicit media classifier marks is rejected with 400 [`EXPLICIT_CONTENT_CANNOT_BE_SENT`](/http-api/errors/), whose body has the classifier score in a top-level `probability` member. An animated avatar requires the animated avatar entitlement and is otherwise rejected with `ANIMATED_AVATARS_REQUIRE_PREMIUM`. A successfully decoded avatar or non-null banner derives its dominant colour and returns it later as `avatar_color` or `banner_color`.
An animated avatar requires the animated avatar entitlement and is otherwise rejected with `ANIMATED_AVATARS_REQUIRE_PREMIUM`. A successfully decoded avatar or non-null banner derives its dominant colour and returns it later as `avatar_color` or `banner_color`.
### Content blocklists
@@ -175,7 +175,7 @@ Supplying `new_password` on a claimed account deletes every other authentication
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [user](/http-api/users/#user-object) object | Account was returned after applying every permitted field |
| 400 | [error response](/http-api/#error-response) | Body, image, tag, password, entitlement, secondary control, or sudo proof is invalid, or the image returns `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
| 400 | [error response](/http-api/#error-response) | Body, image, tag, password, entitlement, secondary control, or sudo proof is invalid |
| 403 | [error response](/http-api/#error-response) | Email verification, sudo verification, or a staff-only field is required, or content is blocked |
### Side effects
@@ -829,8 +829,7 @@ The decoded bytes must be at most the resolved [avatar_max_size](/http-api/insta
| --- | --- | --- |
| 200 | [webhook](#webhook-object) object | Webhook was created |
| 400<sup>1</sup> | [error response](/http-api/#error-response) | Body or avatar is invalid |
| 400<sup>2</sup> | [error response](/http-api/#error-response) | The avatar is blocked as explicit media |
| 400<sup>3</sup> | [error response](/http-api/#error-response) | The guild or channel webhook allowance is already reached |
| 400<sup>2</sup> | [error response](/http-api/#error-response) | The guild or channel webhook allowance is already reached |
| 400 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
| 403 | [error response](/http-api/#error-response) | Credential is a bearer token |
| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action |
@@ -845,15 +844,12 @@ The decoded bytes must be at most the resolved [avatar_max_size](/http-api/insta
<sup>1</sup> An avatar failure names the `avatar` path with `BASE64_LENGTH_INVALID`, `INVALID_BASE64_FORMAT`, `IMAGE_SIZE_EXCEEDS_LIMIT`, or `INVALID_IMAGE_FORMAT`
<sup>2</sup> The classifier block has its score in `probability`
<sup>3</sup> The reached allowance is in a top-level member named after its limit key
<sup>2</sup> The reached allowance is in a top-level member named after its limit key
| Condition | Error |
| --- | --- |
| Guild or channel webhook allowance reached | 400 `MAX_WEBHOOKS_PER_GUILD` or 400 `MAX_WEBHOOKS_PER_CHANNEL` |
| Caller holds the permission but has no enrolled authenticator | 400 `TWO_FACTOR_REQUIRED` |
| Explicit media classifier blocks the avatar | 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
| Schema or image failure | 400 `INVALID_FORM_BODY` |
| Name is blocked, or the avatar hash is banned | 403 `CONTENT_BLOCKED` |
| Account has an outstanding required action | 403 `ACCOUNT_SUSPICIOUS_ACTIVITY` |
@@ -946,7 +942,6 @@ The returned webhook object has the destination channel.
| --- | --- | --- |
| 200 | [webhook](#webhook-object) object | Webhook was updated |
| 400 | [error response](/http-api/#error-response) | Body or avatar is invalid |
| 400 | [error response](/http-api/#error-response) | The avatar is blocked as explicit media |
| 400<sup>1</sup> | [error response](/http-api/#error-response) | The destination channel already holds its maximum webhooks |
| 400 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
| 403 | [error response](/http-api/#error-response) | Credential is a bearer token |
@@ -969,7 +964,6 @@ The returned webhook object has the destination channel.
| --- | --- |
| Destination channel already holds its maximum webhooks | 400 `MAX_WEBHOOKS_PER_CHANNEL` |
| Caller holds the permission but has no enrolled authenticator | 400 `TWO_FACTOR_REQUIRED` |
| Explicit media classifier blocks the avatar | 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
| Schema or image failure | 400 `INVALID_FORM_BODY` |
| Name is blocked, or the avatar hash is banned | 403 `CONTENT_BLOCKED` |
| Account has an outstanding required action | 403 `ACCOUNT_SUSPICIOUS_ACTIVITY` |
@@ -1094,7 +1088,6 @@ Unlike [update webhook](#update-webhook), this body rejects any field it does no
| 200 | [token webhook](#token-webhook-object) object | Webhook was updated |
| 400 | [error response](/http-api/#error-response) | Path, body, or avatar is invalid |
| 400 | [error response](/http-api/#error-response) | The body has an unknown field |
| 400 | [error response](/http-api/#error-response) | The avatar is blocked as explicit media, returning `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
| 403 | [error response](/http-api/#error-response) | Name is blocked or the avatar hash is banned, each returning `CONTENT_BLOCKED` |
| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
@@ -1220,7 +1213,7 @@ An attachment metadata entry whose `id` matches a supplied file index supplies t
The operation creates one webhook-authored message, attaches direct multipart files, and resolves forward snapshots and mentions under the allowed mentions policy.
A supplied `username` replaces the author name on this message. A supplied `avatar_url` is fetched for this message, and the webhook's stored name and avatar do not change. When the avatar cannot be fetched, or the explicit media classifier blocks it, Fluxer creates the message without the override.
A supplied `username` replaces the author name on this message. A supplied `avatar_url` is fetched for this message, and the webhook's stored name and avatar do not change. When the avatar cannot be fetched, Fluxer creates the message without the override.
The operation updates channel state and search results and emits [Message Create](/gateway/events/#message-create) to sessions that can read the channel.
@@ -1454,7 +1447,7 @@ The success body is the literal string `ok` under the `text/html` content type.
The converted callback creates one webhook-authored message with the converted content and embeds, and emits [Message Create](/gateway/events/#message-create) to sessions that can read the channel.
A supplied username replaces the author name on that message. A supplied `icon_url` that parses as an absolute URL is fetched through the media boundary and stored as the message avatar, and the stored webhook name and avatar do not change. When the URL cannot be fetched, or the explicit media classifier blocks the image, the callback still succeeds and the message has no avatar override.
A supplied username replaces the author name on that message. A supplied `icon_url` that parses as an absolute URL is fetched through the media boundary and stored as the message avatar, and the stored webhook name and avatar do not change. When the URL cannot be fetched, the callback still succeeds and the message has no avatar override.
The webhook execution default applies, and every mention in the converted content is suppressed. The callback has no nonce, so a repeated callback creates a second message.
@@ -54,6 +54,8 @@ The rename also moves the `caddy-data` and `caddy-config` volumes to `edge-data`
The refreshed `docker-compose.yml` requires `FLUXER_ERLANG_COOKIE`. An `.env` written by an earlier installer has no such line, so add `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` to it before you upgrade. The value is 64 hex characters, which is what a fresh install writes. Until `.env` sets it, every Compose command against the stack stops with `set FLUXER_ERLANG_COOKIE in .env`.
`api` and `media-proxy` now refuse to start unless `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64` decodes to at least 32 bytes. The line has been in `.env.example` for a while as `CHANGE_ME`, which decodes to 6 bytes, and nothing read it before, so an instance can be running today with the placeholder. Set it with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=$(openssl rand -base64 32)` before you upgrade. Both services read the same value, so give them one secret rather than two. A fresh install writes it for you. Without it `api` stops at boot with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API`.
## The script is the reference
Every step above sits in the script beside a comment holding the command that does that step alone and the reason the step exists. Read [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1) for Windows.
@@ -1444,9 +1444,6 @@ export const ReportAdminResponseSchema = z.object({
message_context: z.array(ReportMessageContextSchema).optional(),
message_responses: z.array(MessageResponseSchema).optional(),
});
const ListReportsResponse = z.object({
reports: z.array(ReportAdminResponseSchema),
});
export const ResolveReportResponse = z.object({
report_id: SnowflakeStringType,
status: ReportStatusSchema,
@@ -1459,7 +1456,7 @@ const SearchReportsResponse = z.object({
offset: z.number(),
limit: z.number(),
});
export const AdminReportListResponse = z.union([SearchReportsResponse, ListReportsResponse]);
export const AdminReportListResponse = SearchReportsResponse;
const LimitKeyMetadataSchema = z.object({
key: z.string(),
label: z.string(),