From d7db4b92cdcdcd3c28e7b12f15d577f3904bbb2e Mon Sep 17 00:00:00 2001
From: phil_00 <162841065+crazifil@users.noreply.github.com>
Date: Thu, 6 Aug 2026 21:32:56 +0100
Subject: [PATCH 1/6] Add News backend infrastructure
- Add News database migration
- Add News repository, service and controller
- Register News API routes
- Add public News page
- Restore original News button
---
.../20260806195442_create_news_page.ts | 37 +++++++
api/src/api.ts | 2 +
api/src/controllers/news.controller.ts | 98 +++++++++++++++++++
api/src/repositories/index.ts | 1 +
api/src/repositories/news/news.repository.ts | 34 +++++++
api/src/routes/index.ts | 1 +
api/src/routes/news.routes.ts | 17 ++++
api/src/services/index.ts | 1 +
api/src/services/news/news.service.ts | 32 ++++++
spa/src/App.vue | 7 +-
spa/src/pages/News.vue | 27 +++++
spa/src/routes.ts | 9 ++
12 files changed, 265 insertions(+), 1 deletion(-)
create mode 100644 api/db/migrations/20260806195442_create_news_page.ts
create mode 100644 api/src/controllers/news.controller.ts
create mode 100644 api/src/repositories/news/news.repository.ts
create mode 100644 api/src/routes/news.routes.ts
create mode 100644 api/src/services/news/news.service.ts
create mode 100644 spa/src/pages/News.vue
diff --git a/api/db/migrations/20260806195442_create_news_page.ts b/api/db/migrations/20260806195442_create_news_page.ts
new file mode 100644
index 00000000..f3e6afb6
--- /dev/null
+++ b/api/db/migrations/20260806195442_create_news_page.ts
@@ -0,0 +1,37 @@
+import { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise {
+ console.log('Creating news table...');
+
+ await knex.schema.createTable('news', table => {
+ table.increments('id').primary();
+
+ table
+ .specificType('html', 'LONGTEXT')
+ .notNullable();
+
+ table
+ .integer('updated_by_member_id')
+ .unsigned()
+ .nullable()
+ .references('id')
+ .inTable('member')
+ .onDelete('SET NULL');
+
+ table
+ .timestamp('created_at')
+ .notNullable()
+ .defaultTo(knex.fn.now());
+
+ table
+ .timestamp('updated_at')
+ .notNullable()
+ .defaultTo(knex.fn.now());
+ });
+}
+
+export async function down(knex: Knex): Promise {
+ console.log('Dropping news_page table...');
+
+ await knex.schema.dropTableIfExists('news');
+}
\ No newline at end of file
diff --git a/api/src/api.ts b/api/src/api.ts
index df874925..849c84a6 100644
--- a/api/src/api.ts
+++ b/api/src/api.ts
@@ -19,6 +19,7 @@ import {
memberRoutes,
messageRoutes,
messageboardRoutes,
+ newsRoutes,
objectInstanceRoutes,
objectRoutes,
placeRoutes,
@@ -68,6 +69,7 @@ app.use('/api/blackmarket', blackmarketRoutes);
app.use('/api/fleamarket', fleamarketRoutes);
app.use('/api/club', clubRoutes);
app.use('/api/vote', voteRoutes);
+app.use('/api/news', newsRoutes);
app.use((request, response, next) => {
const error = new Error('Not found');
diff --git a/api/src/controllers/news.controller.ts b/api/src/controllers/news.controller.ts
new file mode 100644
index 00000000..26549591
--- /dev/null
+++ b/api/src/controllers/news.controller.ts
@@ -0,0 +1,98 @@
+import { Request, Response } from 'express';
+import { Container } from 'typedi';
+
+import {
+ MemberService,
+ NewsService,
+} from '../services';
+
+class NewsController {
+ constructor(
+ private memberService: MemberService,
+ private newsService: NewsService,
+ ) {}
+
+ public async getNews(
+ request: Request,
+ response: Response,
+ ): Promise {
+ try {
+ const news = await this.newsService.getNews();
+
+ response.status(200).json({
+ news: news || null,
+ });
+ } catch (error) {
+ console.log(error);
+
+ response.status(400).json({
+ error: 'A problem occurred while trying to fetch the News page.',
+ });
+ }
+ }
+
+ public async updateNews(
+ request: Request,
+ response: Response,
+ ): Promise {
+ const session = this.memberService.decryptSession(
+ request,
+ response,
+ );
+
+ if (!session) {
+ return;
+ }
+
+ const accessLevel = await this.memberService.getAccessLevel(
+ session.id,
+ );
+
+ if (!accessLevel.includes('admin')) {
+ response.status(403).json({
+ error: 'Access Denied',
+ });
+
+ return;
+ }
+
+ const uncleanHtml = request.body.html;
+
+ if (typeof uncleanHtml !== 'string') {
+ response.status(400).json({
+ error: 'News HTML is required.',
+ });
+
+ return;
+ }
+
+ const cleanHtml = await this.newsService.sanitize(
+ uncleanHtml,
+ );
+
+ try {
+ await this.newsService.updateNews(
+ cleanHtml,
+ session.id,
+ );
+
+ response.status(200).json({
+ success: 'News updated',
+ });
+ } catch (error) {
+ console.log(error);
+
+ response.status(400).json({
+ error: 'A problem occurred while trying to update the News page.',
+ });
+ }
+ }
+}
+
+const memberService = Container.get(MemberService);
+const newsService = Container.get(NewsService);
+
+export const newsController = new NewsController(
+ memberService,
+ newsService,
+);
\ No newline at end of file
diff --git a/api/src/repositories/index.ts b/api/src/repositories/index.ts
index 7936f133..13ee3edc 100644
--- a/api/src/repositories/index.ts
+++ b/api/src/repositories/index.ts
@@ -21,3 +21,4 @@ export * from './wallet/wallet.repository';
export * from './messageboard/messageboard.repository';
export * from './inbox/inbox.repository';
export * from './virtual_pet/virtual-pet.repository';
+export * from './news/news.repository';
\ No newline at end of file
diff --git a/api/src/repositories/news/news.repository.ts b/api/src/repositories/news/news.repository.ts
new file mode 100644
index 00000000..7184d47d
--- /dev/null
+++ b/api/src/repositories/news/news.repository.ts
@@ -0,0 +1,34 @@
+import { Service } from 'typedi';
+
+import { knex } from '../../db';
+
+@Service()
+export class NewsRepository {
+ public async getNews(): Promise {
+ return knex('news').first();
+ }
+
+ public async updateNews(
+ html: string,
+ memberId: number,
+ ): Promise {
+ const existingNews = await knex('news').first();
+
+ if (existingNews) {
+ await knex('news')
+ .where('id', existingNews.id)
+ .update({
+ html,
+ updated_by_member_id: memberId,
+ updated_at: knex.fn.now(),
+ });
+
+ return;
+ }
+
+ await knex('news').insert({
+ html,
+ updated_by_member_id: memberId,
+ });
+ }
+}
\ No newline at end of file
diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts
index f3da49a6..f1099528 100644
--- a/api/src/routes/index.ts
+++ b/api/src/routes/index.ts
@@ -10,6 +10,7 @@ export * from './hood.routes';
export * from './mall.routes';
export * from './member.routes';
export * from './message.routes';
+export * from './news.routes';
export * from './object-instance.routes';
export * from './object.routes';
export * from './place.routes';
diff --git a/api/src/routes/news.routes.ts b/api/src/routes/news.routes.ts
new file mode 100644
index 00000000..4c56fb8d
--- /dev/null
+++ b/api/src/routes/news.routes.ts
@@ -0,0 +1,17 @@
+import { Router } from 'express';
+
+import { newsController } from '../controllers/news.controller';
+
+export const newsRoutes = Router();
+
+newsRoutes.get(
+ '/',
+ (request, response) =>
+ newsController.getNews(request, response),
+);
+
+newsRoutes.put(
+ '/',
+ (request, response) =>
+ newsController.updateNews(request, response),
+);
\ No newline at end of file
diff --git a/api/src/services/index.ts b/api/src/services/index.ts
index 9c0b10ec..6923fcd4 100644
--- a/api/src/services/index.ts
+++ b/api/src/services/index.ts
@@ -19,3 +19,4 @@ export * from './wallet/wallet.service';
export * from './messageboard/messageboard.service';
export * from './inbox/inbox.service';
export * from './vote/vote.service';
+export * from './news/news.service';
\ No newline at end of file
diff --git a/api/src/services/news/news.service.ts b/api/src/services/news/news.service.ts
new file mode 100644
index 00000000..4d05afb9
--- /dev/null
+++ b/api/src/services/news/news.service.ts
@@ -0,0 +1,32 @@
+import { Service } from 'typedi';
+
+import sanitizeHtml from 'sanitize-html';
+
+import { NewsRepository } from '../../repositories/news/news.repository';
+
+@Service()
+export class NewsService {
+ constructor(
+ private newsRepository: NewsRepository,
+ ) {}
+
+ public async sanitize(
+ html: string,
+ ): Promise {
+ return sanitizeHtml(html);
+ }
+
+ public async getNews(): Promise {
+ return this.newsRepository.getNews();
+ }
+
+ public async updateNews(
+ html: string,
+ memberId: number,
+ ): Promise {
+ return this.newsRepository.updateNews(
+ html,
+ memberId,
+ );
+ }
+}
\ No newline at end of file
diff --git a/spa/src/App.vue b/spa/src/App.vue
index a4b7171e..b762adec 100644
--- a/spa/src/App.vue
+++ b/spa/src/App.vue
@@ -12,7 +12,12 @@
-

+
diff --git a/spa/src/pages/News.vue b/spa/src/pages/News.vue
new file mode 100644
index 00000000..59533608
--- /dev/null
+++ b/spa/src/pages/News.vue
@@ -0,0 +1,27 @@
+
+
+
+
+ Cybertown News
+
+
+
+
+
+ Welcome to the new Cybertown News page.
+
+
+
+ This feature is currently under development.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spa/src/routes.ts b/spa/src/routes.ts
index a32e989c..f3d89182 100644
--- a/spa/src/routes.ts
+++ b/spa/src/routes.ts
@@ -1,4 +1,5 @@
import HomePage from "./pages/HomePage.vue";
+import NewsPage from "./pages/News.vue";
import AboutPage from "./pages/AboutPage.vue";
import ConstitutionPage from './pages/Constitution.vue';
import RulesRegulationsPage from "./pages/RulesandRegulationPage.vue";
@@ -105,6 +106,14 @@ export default [
title: "Welcome to Cybertown",
},
},
+ {
+ path: "/News",
+ component: NewsPage,
+ name: "news",
+ meta: {
+ title: "Cybertown Virtual News",
+ },
+ },
{
path: "/about",
component: AboutPage,
From 18baef9a362cb8892e91fd671d6b04c8b0dccf0b Mon Sep 17 00:00:00 2001
From: phil_00 <162841065+crazifil@users.noreply.github.com>
Date: Thu, 6 Aug 2026 21:50:39 +0100
Subject: [PATCH 2/6] Rename news_page table to news
Renaming table to fit naming convention
---
.../20260806195442_create_news_page.ts | 2 +-
spa/src/pages/News.vue | 80 ++++++++++++++++---
2 files changed, 71 insertions(+), 11 deletions(-)
diff --git a/api/db/migrations/20260806195442_create_news_page.ts b/api/db/migrations/20260806195442_create_news_page.ts
index f3e6afb6..d6a134db 100644
--- a/api/db/migrations/20260806195442_create_news_page.ts
+++ b/api/db/migrations/20260806195442_create_news_page.ts
@@ -31,7 +31,7 @@ export async function up(knex: Knex): Promise {
}
export async function down(knex: Knex): Promise {
- console.log('Dropping news_page table...');
+ console.log('Dropping news table...');
await knex.schema.dropTableIfExists('news');
}
\ No newline at end of file
diff --git a/spa/src/pages/News.vue b/spa/src/pages/News.vue
index 59533608..c327e4cb 100644
--- a/spa/src/pages/News.vue
+++ b/spa/src/pages/News.vue
@@ -1,27 +1,87 @@
-
-
+
Cybertown News
-
+
+ Loading News...
+
+
+
+ {{ error }}
+
+
+
+ No News has been published yet.
+
-
- Welcome to the new Cybertown News page.
-
+
+
-
- This feature is currently under development.
-
+
+
+ Last updated:
+ {{ formatDate(news.updated_at) }}
+
+
\ No newline at end of file
From 60fe20a32b31bf7206684b2eee046e4a5e6ee3e9 Mon Sep 17 00:00:00 2001
From: phil_00 <162841065+crazifil@users.noreply.github.com>
Date: Thu, 6 Aug 2026 22:17:08 +0100
Subject: [PATCH 3/6] Add News Admin Editor
- Add News editor to the admin panel
- Load News content from the API
- Save News updates to the database
- Add live preview
- Connect public News page to backend
---
api/src/routes/news.routes.ts | 2 +-
spa/src/pages/admin/admin.vue | 3 +
spa/src/pages/admin/news/editor.vue | 112 ++++++++++++++++++++++++++++
spa/src/routes.ts | 9 +++
4 files changed, 125 insertions(+), 1 deletion(-)
create mode 100644 spa/src/pages/admin/news/editor.vue
diff --git a/api/src/routes/news.routes.ts b/api/src/routes/news.routes.ts
index 4c56fb8d..e468abf5 100644
--- a/api/src/routes/news.routes.ts
+++ b/api/src/routes/news.routes.ts
@@ -10,7 +10,7 @@ newsRoutes.get(
newsController.getNews(request, response),
);
-newsRoutes.put(
+newsRoutes.post(
'/',
(request, response) =>
newsController.updateNews(request, response),
diff --git a/spa/src/pages/admin/admin.vue b/spa/src/pages/admin/admin.vue
index 7ed83606..efff7dfd 100644
--- a/spa/src/pages/admin/admin.vue
+++ b/spa/src/pages/admin/admin.vue
@@ -18,6 +18,9 @@
Roles
+
+ News
+
Places
diff --git a/spa/src/pages/admin/news/editor.vue b/spa/src/pages/admin/news/editor.vue
new file mode 100644
index 00000000..96348d22
--- /dev/null
+++ b/spa/src/pages/admin/news/editor.vue
@@ -0,0 +1,112 @@
+
+
+
+ Edit Cybertown News
+
+
+
+ {{ success }}
+
+
+
+ {{ error }}
+
+
+
+ Loading News...
+
+
+
+
+ Enter the approved HTML for the News page:
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spa/src/routes.ts b/spa/src/routes.ts
index f3d89182..54d2dd6d 100644
--- a/spa/src/routes.ts
+++ b/spa/src/routes.ts
@@ -69,6 +69,7 @@ import CityRoles from "@/pages/admin/roles/roles.vue";
import Transactions from "@/pages/admin/transactions/search.vue";
import UserObjectSearch from "@/pages/admin/objects/instances/search.vue";
import SeizedObjects from "@/pages/admin/objects/instances/seized.vue";
+import NewsEditor from "@/pages/admin/news/editor.vue";
import MallRulesPage from "@/pages/mall/MallRulesPage.vue";
import MallUploadPage from "@/pages/mall/MallUploadPage.vue";
@@ -476,6 +477,14 @@ export default [
title: "Member Search - Admin Panel",
},
},
+ {
+ path: "/admin/news",
+ component: NewsEditor,
+ name: "NewsEditor",
+ meta: {
+ title: "Edit Cybertown News",
+ },
+},
{
path: "/admin/member/user/:id",
component: UserMain,
From e85a422d7c1c3db697327c865973746b81136687 Mon Sep 17 00:00:00 2001
From: phil_00 <162841065+crazifil@users.noreply.github.com>
Date: Thu, 6 Aug 2026 22:25:47 +0100
Subject: [PATCH 4/6] Display News content from DB
- Load News from the API
- Display HTML content on the public News page
- Show last updated timestamp
- Show the username of the last editor
---
api/src/repositories/news/news.repository.ts | 20 +++++++++++++++++---
spa/src/pages/News.vue | 15 ++++++++++++---
2 files changed, 29 insertions(+), 6 deletions(-)
diff --git a/api/src/repositories/news/news.repository.ts b/api/src/repositories/news/news.repository.ts
index 7184d47d..b0ab1af5 100644
--- a/api/src/repositories/news/news.repository.ts
+++ b/api/src/repositories/news/news.repository.ts
@@ -4,9 +4,23 @@ import { knex } from '../../db';
@Service()
export class NewsRepository {
- public async getNews(): Promise {
- return knex('news').first();
- }
+public async getNews(): Promise {
+ return knex('news')
+ .leftJoin(
+ 'member',
+ 'news.updated_by_member_id',
+ 'member.id',
+ )
+ .select(
+ 'news.id',
+ 'news.html',
+ 'news.updated_by_member_id',
+ 'news.created_at',
+ 'news.updated_at',
+ 'member.username as updated_by_username',
+ )
+ .first();
+}
public async updateNews(
html: string,
diff --git a/spa/src/pages/News.vue b/spa/src/pages/News.vue
index c327e4cb..5fcdc5bf 100644
--- a/spa/src/pages/News.vue
+++ b/spa/src/pages/News.vue
@@ -25,9 +25,17 @@
- Last updated:
- {{ formatDate(news.updated_at) }}
-
+ Last updated:
+ {{ formatDate(news.updated_at) }}
+
+
+
+ Updated by:
+ {{ news.updated_by_username }}
+
@@ -39,6 +47,7 @@ interface NewsRecord {
id: number;
html: string;
updated_by_member_id: number | null;
+ updated_by_username: string | null;
created_at: string;
updated_at: string;
}
From 45d32d1b7c294c1ee025c507a117c50765c57b7c Mon Sep 17 00:00:00 2001
From: phil_00 <162841065+crazifil@users.noreply.github.com>
Date: Thu, 6 Aug 2026 23:36:54 +0100
Subject: [PATCH 5/6] Implement role=based news editor permissions
Adding access to Mayor, CVN Editor and remaining access for admin
---
api/src/controllers/news.controller.ts | 44 +++++++--
api/src/routes/news.routes.ts | 6 ++
api/src/services/member/member.service.ts | 14 +++
spa/assets/img/anchor.jpg | Bin 0 -> 205172 bytes
spa/src/pages/News.vue | 81 ++++++++++++-----
spa/src/pages/admin/admin.vue | 105 +++++++++++++++++-----
spa/src/pages/admin/news/editor.vue | 5 +-
7 files changed, 197 insertions(+), 58 deletions(-)
create mode 100644 spa/assets/img/anchor.jpg
diff --git a/api/src/controllers/news.controller.ts b/api/src/controllers/news.controller.ts
index 26549591..a125ef3a 100644
--- a/api/src/controllers/news.controller.ts
+++ b/api/src/controllers/news.controller.ts
@@ -30,7 +30,34 @@ class NewsController {
});
}
}
+public async canEditNews(
+ request: Request,
+ response: Response,
+): Promise {
+ const session = this.memberService.decryptSession(
+ request,
+ response,
+ );
+
+ if (!session) {
+ return;
+ }
+
+ try {
+ const canEdit =
+ await this.memberService.canEditNews(session.id);
+
+ response.status(200).json({
+ canEdit,
+ });
+ } catch (error) {
+ console.log(error);
+ response.status(400).json({
+ error: 'A problem occurred while checking News permissions.',
+ });
+ }
+}
public async updateNews(
request: Request,
response: Response,
@@ -44,17 +71,16 @@ class NewsController {
return;
}
- const accessLevel = await this.memberService.getAccessLevel(
- session.id,
- );
+ const canEditNews =
+ await this.memberService.canEditNews(session.id);
- if (!accessLevel.includes('admin')) {
- response.status(403).json({
- error: 'Access Denied',
- });
+if (!canEditNews) {
+ response.status(403).json({
+ error: 'Access Denied',
+ });
- return;
- }
+ return;
+}
const uncleanHtml = request.body.html;
diff --git a/api/src/routes/news.routes.ts b/api/src/routes/news.routes.ts
index e468abf5..5b237d56 100644
--- a/api/src/routes/news.routes.ts
+++ b/api/src/routes/news.routes.ts
@@ -10,6 +10,12 @@ newsRoutes.get(
newsController.getNews(request, response),
);
+newsRoutes.get(
+ '/can-edit',
+ (request, response) =>
+ newsController.canEditNews(request, response),
+);
+
newsRoutes.post(
'/',
(request, response) =>
diff --git a/api/src/services/member/member.service.ts b/api/src/services/member/member.service.ts
index 8f033d45..51cbab5b 100644
--- a/api/src/services/member/member.service.ts
+++ b/api/src/services/member/member.service.ts
@@ -121,6 +121,20 @@ export class MemberService {
return accessLevel;
}
+ public async canEditNews(memberId: number): Promise {
+ const roleAssignments =
+ await this.roleAssignmentRepository.getByMemberId(memberId);
+
+ const NEWS_ROLES = [
+ this.roleRepository.roleMap.Admin,
+ this.roleRepository.roleMap.CityMayor,
+ this.roleRepository.roleMap.CVNEditor,
+ ];
+
+ return !!roleAssignments.find(
+ assignment => NEWS_ROLES.includes(assignment.role_id),
+ );
+}
/**
* Creates a new member with the given email, username, and password. If successful, distributes
* daily login bonuses, and returns an encoded member token.
diff --git a/spa/assets/img/anchor.jpg b/spa/assets/img/anchor.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..2e002ede61b35b6f8c868b48dd3732af2a0910b8
GIT binary patch
literal 205172
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