feat: add project updates feed and dedicated page

Proxy and sanitize Gitea repository data via /api/project-updates
Display recent commits and releases preview on the Home page
Add /project-updates route for paginated commit history
This commit is contained in:
2026-05-03 23:40:34 +08:00
parent a0e07f101a
commit 8dfd03f3d2
9 changed files with 1207 additions and 2 deletions

View File

@@ -212,6 +212,287 @@ function requestLocale(request: FastifyRequest): string {
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
}
type ProjectUpdatesRepository = {
name: string;
fullName: string;
url: string;
defaultBranch: string;
updatedAt: string | null;
};
type ProjectUpdateCommit = {
sha: string;
shortSha: string;
title: string;
message: string;
createdAt: string;
authorName: string;
url: string;
};
type ProjectUpdateRelease = {
tagName: string;
name: string;
publishedAt: string | null;
url: string;
};
type ProjectCommitPage = {
items: ProjectUpdateCommit[];
nextCursor: string | null;
hasMore: boolean;
};
type ProjectUpdatesCursor = {
page: number;
limit: number;
};
type ProjectUpdatesResponse = {
repository: ProjectUpdatesRepository;
commits: ProjectCommitPage;
releases: ProjectUpdateRelease[];
};
const projectUpdatesConfig = {
apiBaseUrl: 'https://git.tootaio.com/api/v1',
publicBaseUrl: 'https://git.tootaio.com',
owner: 'Kingsmai',
repo: 'pokopiawiki.tootaio.com',
commitLimit: 5,
maxCommitLimit: 20,
releaseLimit: 3,
timeoutMs: 5000
} as const;
function projectRepositoryPath(): string {
return `${encodeURIComponent(projectUpdatesConfig.owner)}/${encodeURIComponent(projectUpdatesConfig.repo)}`;
}
function projectRepositoryUrl(): string {
return `${projectUpdatesConfig.publicBaseUrl}/${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`;
}
function projectApiUrl(path = '', params: Record<string, string | number | boolean> = {}): string {
const apiBaseUrl = projectUpdatesConfig.apiBaseUrl.replace(/\/$/, '');
const url = new URL(`${apiBaseUrl}/repos/${projectRepositoryPath()}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
return url.toString();
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function objectField(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
if (!record) return null;
const value = record[key];
return isObjectRecord(value) ? value : null;
}
function stringField(record: Record<string, unknown> | null, key: string): string | null {
if (!record) return null;
const value = record[key];
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
function normalizedDate(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function projectCommitTitle(message: string | null, fallback: string): string {
const [firstLine] = (message ?? '').split('\n');
return firstLine?.trim() || fallback;
}
function projectUpdatesQueryValue(value: string | string[] | undefined): string | null {
const rawValue = Array.isArray(value) ? value[0] : value;
return rawValue?.trim() || null;
}
function cleanProjectUpdatesLimit(query: Record<string, string | string[] | undefined>): number {
const rawLimit = Number(projectUpdatesQueryValue(query.limit));
if (!Number.isInteger(rawLimit)) {
return projectUpdatesConfig.commitLimit;
}
return Math.min(Math.max(rawLimit, 1), projectUpdatesConfig.maxCommitLimit);
}
function encodeProjectUpdatesCursor(page: number, limit: number): string {
return Buffer.from(JSON.stringify({ page, limit }), 'utf8').toString('base64url');
}
function decodeProjectUpdatesCursor(cursor: string | null): ProjectUpdatesCursor | null {
if (!cursor) return null;
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (!isObjectRecord(payload)) {
return null;
}
const { page, limit } = payload;
if (
typeof page === 'number' &&
Number.isInteger(page) &&
page > 0 &&
typeof limit === 'number' &&
Number.isInteger(limit) &&
limit > 0 &&
limit <= projectUpdatesConfig.maxCommitLimit
) {
return { page, limit };
}
return null;
} catch {
return null;
}
}
function fallbackProjectRepository(): ProjectUpdatesRepository {
return {
name: projectUpdatesConfig.repo,
fullName: `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
url: projectRepositoryUrl(),
defaultBranch: 'main',
updatedAt: null
};
}
async function fetchProjectJson(path = '', params: Record<string, string | number | boolean> = {}): Promise<unknown> {
const response = await fetch(projectApiUrl(path, params), {
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(projectUpdatesConfig.timeoutMs)
});
if (!response.ok) {
throw new Error(`Project updates source failed (${response.status})`);
}
return response.json() as Promise<unknown>;
}
function mapProjectRepository(value: unknown): ProjectUpdatesRepository {
if (!isObjectRecord(value)) {
return fallbackProjectRepository();
}
return {
name: stringField(value, 'name') ?? projectUpdatesConfig.repo,
fullName: stringField(value, 'full_name') ?? `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
url: projectRepositoryUrl(),
defaultBranch: stringField(value, 'default_branch') ?? 'main',
updatedAt: normalizedDate(stringField(value, 'updated_at'))
};
}
function mapProjectCommit(value: unknown): ProjectUpdateCommit | null {
if (!isObjectRecord(value)) return null;
const sha = stringField(value, 'sha');
if (!sha) return null;
const commit = objectField(value, 'commit');
const commitAuthor = objectField(commit, 'author');
const message = stringField(commit, 'message') ?? sha.slice(0, 7);
const fallback = sha.slice(0, 7);
const createdAt =
normalizedDate(stringField(value, 'created')) ??
normalizedDate(stringField(commitAuthor, 'date')) ??
normalizedDate(stringField(objectField(commit, 'committer'), 'date'));
if (!createdAt) return null;
return {
sha,
shortSha: sha.slice(0, 7),
title: projectCommitTitle(message, fallback),
message,
createdAt,
authorName:
stringField(commitAuthor, 'name') ??
stringField(objectField(value, 'author'), 'login') ??
projectUpdatesConfig.owner,
url: `${projectRepositoryUrl()}/commit/${sha}`
};
}
function mapProjectRelease(value: unknown): ProjectUpdateRelease | null {
if (!isObjectRecord(value)) return null;
const tagName = stringField(value, 'tag_name');
if (!tagName) return null;
return {
tagName,
name: stringField(value, 'name') ?? tagName,
publishedAt: normalizedDate(stringField(value, 'published_at')) ?? normalizedDate(stringField(value, 'created_at')),
url: `${projectRepositoryUrl()}/releases/tag/${encodeURIComponent(tagName)}`
};
}
function logProjectUpdatesError(source: string, error: unknown): void {
app.log.warn({ err: error, source }, 'Project updates source unavailable');
}
async function getProjectCommitPage(query: Record<string, string | string[] | undefined>): Promise<ProjectCommitPage> {
const cursor = decodeProjectUpdatesCursor(projectUpdatesQueryValue(query.cursor));
const limit = cursor?.limit ?? cleanProjectUpdatesLimit(query);
const page = cursor?.page ?? 1;
const value = await fetchProjectJson('/commits', {
page,
limit: limit + 1,
stat: false,
files: false,
verification: false
});
const commits = Array.isArray(value)
? value.map(mapProjectCommit).filter((commit): commit is ProjectUpdateCommit => commit !== null)
: [];
const hasMore = commits.length > limit;
return {
items: commits.slice(0, limit),
nextCursor: hasMore ? encodeProjectUpdatesCursor(page + 1, limit) : null,
hasMore
};
}
async function getProjectUpdates(query: Record<string, string | string[] | undefined> = {}): Promise<ProjectUpdatesResponse> {
const [repository, commits, releases] = await Promise.all([
fetchProjectJson()
.then(mapProjectRepository)
.catch((error: unknown) => {
logProjectUpdatesError('repository', error);
return fallbackProjectRepository();
}),
getProjectCommitPage(query).catch((error: unknown) => {
logProjectUpdatesError('commits', error);
throw error;
}),
fetchProjectJson('/releases', { limit: projectUpdatesConfig.releaseLimit, draft: false, 'pre-release': false })
.then((value) =>
Array.isArray(value) ? value.map(mapProjectRelease).filter((release): release is ProjectUpdateRelease => release !== null) : []
)
.catch((error: unknown) => {
logProjectUpdatesError('releases', error);
return [];
})
]);
return { repository, commits, releases };
}
function serverMessage(
locale: string,
key:
@@ -844,6 +1125,10 @@ app.get('/api/system-wordings', async (request) => getSystemWordings(requestLoca
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
app.get('/api/project-updates', async (request) =>
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
);
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
app.get('/api/users/:id/profile', async (request, reply) => {