diff --git a/express.js b/express.js index b5a92800..4da08f03 100644 --- a/express.js +++ b/express.js @@ -1,192 +1,9 @@ -const appConfig = require('./config/app') -const express = require('express'); -const showdown = require('showdown'); -const passport = require('passport'); -const session = require('express-session'); -const FileStore = require('session-file-store')(session); -const bodyParser = require('body-parser'); -const flash = require('connect-flash'); -const setupCronJobs = require('./scripts/cron-jobs'); -const middleware = require('./routes/middleware'); -const app = express(); -const newsRouter = require('./routes/views/news'); -const staticMarkdownRouter = require('./routes/views/staticMarkdownRouter'); -const leaderboardRouter = require('./routes/views/leaderboardRouter'); -const authRouter = require('./routes/views/auth'); +const fafApp = require('./fafApp') +const express = require('express') +const app = express() -app.locals.clanInvitations = {}; +fafApp.setup(app) +fafApp.loadRouters(app) +fafApp.setupCronJobs() -//Execute Middleware -app.use(middleware.initLocals); -app.use(middleware.clientChecks); - -//Set static public directory path -app.use(express.static('public', { - immutable: true, - maxAge: 4 * 60 * 60 * 1000 // 4 hours -})); - -app.use(express.json()); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({extended: false})); - -app.use(session({ - resave: false, - saveUninitialized: true, - secret: appConfig.session.key, - store: new FileStore({ - retries: 0, - ttl: appConfig.session.tokenLifespan, - secret: appConfig.session.key - }) -})); - -app.use(passport.initialize()); -app.use(passport.session()); -app.use(flash()); -app.use(middleware.username); -app.use(middleware.flashMessage); - -//Initialize values for default configs -app.set('views', 'templates/views'); -app.set('view engine', 'pug'); -app.set('port', appConfig.expressPort); - -app.use(function(req, res, next){ - res.locals.message = req.flash(); - next(); -}); - -let fullUrl = '/'; - -function loggedIn(req, res, next) { - - fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; - - if (req.isAuthenticated()) { - res.locals.username = req.user.data.attributes.userName; - next(); - } else { - res.redirect('/login'); - } -} - -app.use('/', authRouter) -app.use('/', staticMarkdownRouter) -app.use('/news', newsRouter) -app.use('/leaderboards', leaderboardRouter) - -// --- UNPROTECTED ROUTES --- -const appGetRouteArray = [ - // This first '' is the home/index page - '', 'newshub', 'campaign-missions', 'scfa-vs-faf', 'donation', 'tutorials-guides', 'ai', 'patchnotes', 'faf-teams', 'contribution', 'content-creators', 'tournaments', 'training', 'play', 'clans',]; - -//Renders every page written above -appGetRouteArray.forEach(page => app.get(`/${page}`, (req, res) => { - // disabled due https://github.com/FAForever/website/issues/445 - if (page === 'clans') { - return res.status(503).render('errors/503-known-issue') - } - res.render(page); -})); - -/* -// List of route name changes - reset > requestPasswordReset - confirmReset > confirmPasswordReset - change > changePassword, changeUsername, changeEmail - */ - -// Account routes -// These routes are protected by the 'loggedIn' function (which verifies if user is serialized/deserialized or logged in [same thing]). -const accountRoutePath = './routes/views/account'; -const protectedAccountRoutes = [ - 'linkGog', 'report', 'changePassword', 'changeEmail', 'changeUsername',]; - -protectedAccountRoutes.forEach(page => app.post(`/account/${page}`, loggedIn, require(`${accountRoutePath}/post/${page}`))); - -protectedAccountRoutes.forEach(page => app.get(`/account/${page}`, loggedIn, require(`${accountRoutePath}/get/${page}`))); - - -//Password reset routes -const passwordResetRoutes = ['requestPasswordReset', 'confirmPasswordReset']; - -passwordResetRoutes.forEach(page => app.post(`/account/${page}`, require(`${accountRoutePath}/post/${page}`))); -passwordResetRoutes.forEach(page => app.get(`/account/${page}`, require(`${accountRoutePath}/get/${page}`))); - -app.get('/account/password/confirmReset', require(`${accountRoutePath}/get/confirmPasswordReset`)); -app.post('/account/password/confirmReset', require(`${accountRoutePath}/post/confirmPasswordReset`)); - -//legacy password reset path for backwards compatibility -app.get('/account/password/reset', require(`${accountRoutePath}/get/requestPasswordReset`)); -app.post('/account/password/reset', require(`${accountRoutePath}/post/requestPasswordReset`)); - - -// --- C L A N S --- -const routes = './routes/views/'; - -const clansRoutesGet = [ - 'create', 'manage', 'accept_invite',]; -// disabled due https://github.com/FAForever/website/issues/445 -// clansRoutesGet.forEach(page => app.get(`/clans/${page}`, loggedIn, require(`${routes}clans/get/${page}`))); -clansRoutesGet.forEach(page => app.get(`/clans/${page}`, loggedIn, (req, res) => res.status(503).render('errors/503-known-issue'))); - -const clansRoutesPost = [ - 'create', 'destroy', 'invite', 'kick', 'transfer', 'update', 'leave', 'join',]; -// disabled due https://github.com/FAForever/website/issues/445 -// clansRoutesPost.forEach(page => app.post(`/clans/${page}`, loggedIn, require(`${routes}clans/post/${page}`))); -clansRoutesPost.forEach(page => app.post(`/clans/${page}`, loggedIn, (req, res) => res.status(503).render('errors/503-known-issue'))); - - -// disabled due https://github.com/FAForever/website/issues/445 -//When searching for a specific clan -// app.get('/clans/*', (req, res) => { -// res.render(`clans/seeClan`); -// }); -app.get('/clans/*', (req, res) => res.status(503).render('errors/503-known-issue')); - - -// ---ODD BALLS--- -// Routes that might not be able to be added into the loops due to their nature in naming -/* Removed - client.js (was made its own code below) - lobby_api (not in use in the new website) - */ -// Protected - -app.get('/account/link', loggedIn, require(routes + 'account/get/linkSteam')); -//app.get('/account/linkSteam', loggedIn, require(routes + 'account/get/linkSteam')); -app.get('/account/connect', loggedIn, require(routes + 'account/get/connectSteam')); -//app.get('/account/connectSteam', loggedIn, require(routes + 'account/get/connectSteam')); -app.get('/account/resync', loggedIn, require(routes + 'account/get/resync')); -// Not Protected -app.get('/account/create', require(routes + 'account/get/createAccount')); -app.get('/account_activated', require(routes + 'account/get/register')); -app.get('/account/register', require(routes + 'account/get/register')); -app.post('/account/register', require(routes + 'account/post/register')); - -app.get('/account/activate', require(routes + 'account/get/activate')); -app.post('/account/activate', require(routes + 'account/post/activate')); - -app.get('/account/checkUsername', require('./routes/views/checkUsername')); -app.get('/password_resetted', require(routes + 'account/get/requestPasswordReset')); -app.get('/report_submitted', require(routes + 'account/get/report')); - -setupCronJobs() - -//404 Error Handlers -app.use(function (req, res) { - res.status(404).render('errors/404'); -}); -app.use(function (err, req, res, next) { - console.error('[error] Incoming request to"', req.originalUrl, '"failed with error "', err.toString(), '"') - if (res.headersSent) { - return next(err); - } - - res.status(500).render('errors/500'); -}); - -app.listen(appConfig.expressPort, () => { - console.log(`Express listening on port ${appConfig.expressPort}`); -}); +fafApp.startServer(app) diff --git a/fafApp.js b/fafApp.js new file mode 100644 index 00000000..a338edd0 --- /dev/null +++ b/fafApp.js @@ -0,0 +1,93 @@ +const appConfig = require("./config/app") +const express = require('express') +const bodyParser = require('body-parser') +const session = require('express-session') +const FileStore = require('session-file-store')(session) +const passport = require('passport') +const flash = require('connect-flash') +const middleware = require('./routes/middleware') +const defaultRouter = require("./routes/views/defaultRouter") +const authRouter = require("./routes/views/auth") +const staticMarkdownRouter = require("./routes/views/staticMarkdownRouter") +const newsRouter = require("./routes/views/news") +const leaderboardRouter = require("./routes/views/leaderboardRouter") +const clanRouter = require("./routes/views/clanRouter") +const accountRouter = require("./routes/views/accountRouter") +const setupCronJobs = require("./scripts/cron-jobs") + +const copyFlashHandler = (req, res, next) => { + res.locals.message = req.flash(); + next(); +} +const notFoundHandler = (req, res) => { + res.status(404).render('errors/404'); +} + +const errorHandler = (err, req, res, next) => { + console.error('[error] Incoming request to"', req.originalUrl, '"failed with error "', err.toString(), '"') + if (res.headersSent) { + return next(err); + } + + res.status(500).render('errors/500'); +} + +module.exports.setupCronJobs = () => { + setupCronJobs() +} + +module.exports.startServer = (app) => { + app.listen(appConfig.expressPort, () => { + console.log(`Express listening on port ${appConfig.expressPort}`); + }); +} + +module.exports.loadRouters = (app) => { + app.use('/', defaultRouter) + app.use('/', authRouter) + app.use('/', staticMarkdownRouter) + app.use('/news', newsRouter) + app.use('/leaderboards', leaderboardRouter) + app.use('/clans', clanRouter) + app.use('/account', accountRouter) + + app.use(notFoundHandler) + app.use(errorHandler) +} + +module.exports.setup = (app) => { + app.locals.clanInvitations = {} + + app.set('views', 'templates/views') + app.set('view engine', 'pug') + app.set('port', appConfig.expressPort) + + app.use(middleware.initLocals) + app.use(middleware.clientChecks) + + app.use(express.static('public', { + immutable: true, + maxAge: 4 * 60 * 60 * 1000 // 4 hours + })) + + app.use(express.json()) + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({extended: false})) + + app.use(session({ + resave: false, + saveUninitialized: true, + secret: appConfig.session.key, + store: new FileStore({ + retries: 0, + ttl: appConfig.session.tokenLifespan, + secret: appConfig.session.key + }) + })) + app.use(passport.initialize()) + app.use(passport.session()) + app.use(flash()) + app.use(middleware.username) + app.use(middleware.flashMessage) + app.use(copyFlashHandler) +} diff --git a/routes/views/accountRouter.js b/routes/views/accountRouter.js new file mode 100644 index 00000000..92f38de2 --- /dev/null +++ b/routes/views/accountRouter.js @@ -0,0 +1,42 @@ +const express = require('express'); +const router = express.Router(); +const middlewares = require('../middleware') + + +router.get(`/linkGog`, middlewares.isAuthenticated(), require(`./account/get/linkGog`)) +router.post(`/linkGog`, middlewares.isAuthenticated(), require(`./account/post/linkGog`)) + +router.get(`/report`, middlewares.isAuthenticated(), require(`./account/get/report`)) +router.post(`/report`, middlewares.isAuthenticated(), require(`./account/post/report`)) + +router.get(`/changePassword`, middlewares.isAuthenticated(), require(`./account/get/changePassword`)) +router.post(`/changePassword`, middlewares.isAuthenticated(), require(`./account/post/changePassword`)) + +router.get(`/changeEmail`, middlewares.isAuthenticated(), require(`./account/get/changeEmail`)) +router.post(`/changeEmail`, middlewares.isAuthenticated(), require(`./account/post/changeEmail`)) + +router.get(`/changeUsername`, middlewares.isAuthenticated(), require(`./account/get/changeUsername`)) +router.post(`/changeUsername`, middlewares.isAuthenticated(), require(`./account/post/changeUsername`)) + +router.get('/password/confirmReset', require(`./account/get/confirmPasswordReset`)); +router.post('/password/confirmReset', require(`./account/post/confirmPasswordReset`)); + +router.get(`/requestPasswordReset`, require(`./account/get/requestPasswordReset`)) +router.post(`/requestPasswordReset`, require(`./account/post/requestPasswordReset`)) + +//still used in other applications (user-service, game-client etc.) +router.get('/password/reset', (req, res) => res.redirect('/account/requestPasswordReset')); + +router.get('/register', require('./account/get/register')); +router.post('/register', require('./account/post/register')); + +router.get('/activate', require('./account/get/activate')); +router.post('/activate', require('./account/post/activate')); + +router.get('/checkUsername', require('./checkUsername')); +router.get('/resync', middlewares.isAuthenticated(), require('./account/get/resync')); +router.get('/create', require('./account/get/createAccount')); +router.get('/link', middlewares.isAuthenticated(), require('./account/get/linkSteam')) +router.get('/connect', middlewares.isAuthenticated(), require('./account/get/connectSteam')) + +module.exports = router diff --git a/routes/views/clanRouter.js b/routes/views/clanRouter.js new file mode 100644 index 00000000..5000277f --- /dev/null +++ b/routes/views/clanRouter.js @@ -0,0 +1,7 @@ +const express = require('express'); +const router = express.Router(); + +// This will be replaced soon, therefor I did not spend time on it +router.get('*', (req, res) => res.status(503).render('errors/503-known-issue')); + +module.exports = router diff --git a/routes/views/defaultRouter.js b/routes/views/defaultRouter.js new file mode 100644 index 00000000..7ba628c4 --- /dev/null +++ b/routes/views/defaultRouter.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); + +router.get('/', (req, res) => res.render('index')) +router.get('/newshub', (req, res) => res.render('newshub')) +router.get('/campaign-missions', (req, res) => res.render('campaign-missions')) +router.get('/scfa-vs-faf', (req, res) => res.render('scfa-vs-faf')) +router.get('/ai', (req, res) => res.render('ai')) +router.get('/donation', (req, res) => res.render('donation')) +router.get('/tutorials-guides', (req, res) => res.render('tutorials-guides')) +router.get('/faf-teams', (req, res) => res.render('faf-teams')) +router.get('/contribution', (reqd, res) => res.render('contribution')) +router.get('/content-creators', (reqd, res) => res.render('content-creators')) +router.get('/play', (reqd, res) => res.render('play')) + +module.exports = router diff --git a/tests/helpers/PassportMock.js b/tests/helpers/PassportMock.js new file mode 100644 index 00000000..da6d35a9 --- /dev/null +++ b/tests/helpers/PassportMock.js @@ -0,0 +1,13 @@ +const passport = require('passport') +const StrategyMock = require('./StrategyMock') + +module.exports = function(app, options) { + passport.use(new StrategyMock(options)) + + app.get('/mock-login' , + passport.authenticate('mock', {failureRedirect: '/mock/login', failureFlash: true}), + (req, res) => { + res.redirect('/') + } + ) +}; diff --git a/tests/helpers/StrategyMock.js b/tests/helpers/StrategyMock.js new file mode 100644 index 00000000..e2950bf1 --- /dev/null +++ b/tests/helpers/StrategyMock.js @@ -0,0 +1,28 @@ +const passport = require('passport') +const util = require('util') + +function StrategyMock(options) { + this.name = 'mock' + this.passAuthentication = options.passAuthentication ?? true + this.user = options.user || { + token: 'test-token', + data: { + id: 1, + attributes: { + token: 'test-token' + } + } + } +} + +util.inherits(StrategyMock, passport.Strategy) + +StrategyMock.prototype.authenticate = function authenticate(req) { + if (this.passAuthentication) { + return this.success(this.user) + } + + return this.fail('Unauthorized') +} + +module.exports = StrategyMock diff --git a/tests/integration/IsAuthenticatedMiddleware.test.js b/tests/integration/IsAuthenticatedMiddleware.test.js index ce72ac66..3a0c437a 100644 --- a/tests/integration/IsAuthenticatedMiddleware.test.js +++ b/tests/integration/IsAuthenticatedMiddleware.test.js @@ -1,23 +1,15 @@ const express = require('express') const middlewares = require('../../routes/middleware') -const passport = require("passport"); -const appConfig = require("../../config/app"); const supertestSession = require('supertest-session'); -const session = require('express-session'); +const fafApp = require("../../fafApp"); let testApp = null let testSession = null beforeEach(() => { const app = new express() + fafApp.setup(app) testSession = supertestSession(app) - app.use(session({ - resave: false, - saveUninitialized: true, - secret: appConfig.session.key, - })); - app.use(passport.initialize()) - app.use(passport.session()) testApp = app }) diff --git a/tests/integration/NewsRouter.test.js b/tests/integration/NewsRouter.test.js index ec25d414..8e7d668e 100644 --- a/tests/integration/NewsRouter.test.js +++ b/tests/integration/NewsRouter.test.js @@ -1,12 +1,16 @@ -const request = require('supertest') const express = require('express') -const newsRouter = require( "../../routes/views/news") const fs = require('fs') - -const app = new express(); -app.set('views', 'templates/views'); -app.set('view engine', 'pug'); -app.use("/news", newsRouter) +const supertestSession = require("supertest-session"); +const fafApp = require('../../fafApp') + +let testSession = null +beforeEach(() => { + const app = new express() + fafApp.setup(app) + fafApp.loadRouters(app) + + testSession = supertestSession(app) +}) describe('News Routes', function () { const testFile = fs.readFileSync('tests/integration/testData/news.json',{encoding:'utf8', flag:'r'}) @@ -16,7 +20,7 @@ describe('News Routes', function () { jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(testFile) - const res = await request(app).get('/news'); + const res = await testSession.get('/news'); expect(res.header['content-type']).toBe('text/html; charset=utf-8'); expect(res.statusCode).toBe(200); expect(res.text).toContain('Welcome to the patchnotes for the 3750 patch.'); @@ -30,7 +34,7 @@ describe('News Routes', function () { jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(testFile) - const res = await request(app).get('/news/balance-patch-3750-is-live'); + const res = await testSession.get('/news/balance-patch-3750-is-live'); expect(res.header['content-type']).toBe('text/html; charset=utf-8'); expect(res.statusCode).toBe(200); expect(res.text).toContain('Welcome to the patchnotes for the 3750 patch.'); @@ -41,7 +45,7 @@ describe('News Routes', function () { jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(testFile) - const res = await request(app).get('/news/Balance-Patch-3750-Is-Live'); + const res = await testSession.get('/news/Balance-Patch-3750-Is-Live'); expect(res.statusCode).toBe(301); expect(res.header['location']).toBe('balance-patch-3750-is-live'); }); diff --git a/tests/integration/accountRouter.test.js b/tests/integration/accountRouter.test.js new file mode 100644 index 00000000..d64107a2 --- /dev/null +++ b/tests/integration/accountRouter.test.js @@ -0,0 +1,31 @@ +const express = require('express') +const supertestSession = require("supertest-session") +const fafApp = require('../../fafApp') + +let testSession = null +beforeEach(() => { + const app = new express() + fafApp.setup(app) + fafApp.loadRouters(app) + testSession = supertestSession(app) +}) + +describe('Account Routes', function () { + const arr = [ + '/account/requestPasswordReset', + '/account/password/confirmReset', + '/account/register', + '/account/activate' + ] + + test.each(arr)("responds with OK to %p", (async (route) => { + const res = await testSession.get(route) + expect(res.statusCode).toBe(200) + })) + + test('redirect old pw-reset routes', async () => { + const response = await testSession.get('/account/password/reset') + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/account/requestPasswordReset') + }) +}) diff --git a/tests/integration/clanRouter.test.js b/tests/integration/clanRouter.test.js new file mode 100644 index 00000000..90ff00af --- /dev/null +++ b/tests/integration/clanRouter.test.js @@ -0,0 +1,21 @@ +const express = require('express') +const supertestSession = require("supertest-session"); +const fafApp = require('../../fafApp') + +let testSession = null +beforeEach(async () => { + const app = new express() + fafApp.setup(app) + fafApp.loadRouters(app) + testSession = supertestSession(app) +}) + +describe('Clan Routes', function () { + const arr = ['/clans', '/clans/everything'] + + test.each(arr)("responds with 503 to %p", (async (route) => { + const res = await testSession.get(route) + expect(res.statusCode).toBe(503) + expect(res.text).toContain('Sorry commanders, we failed to build enough pgens and are now in a tech upgrade'); + })) +}) diff --git a/tests/integration/defaultRouter.test.js b/tests/integration/defaultRouter.test.js new file mode 100644 index 00000000..e474e52e --- /dev/null +++ b/tests/integration/defaultRouter.test.js @@ -0,0 +1,33 @@ +const express = require('express') +const supertestSession = require("supertest-session"); +const fafApp = require('../../fafApp') + +let testSession = null +beforeEach(() => { + const app = new express() + fafApp.setup(app) + fafApp.loadRouters(app) + testSession = supertestSession(app) +}) + +describe('Dafault Routes', function () { + const arr = [ + '', + '/', + '/newshub', + '/campaign-missions', + '/scfa-vs-faf', + '/donation', + '/tutorials-guides', + '/ai', + '/faf-teams', + '/contribution', + '/content-creators', + '/play' + ] + + test.each(arr)("responds with OK to %p", (async (route) => { + const res = await testSession.get(route) + expect(res.statusCode).toBe(200) + })); +}) diff --git a/tests/integration/leaderboardRouter.test.js b/tests/integration/leaderboardRouter.test.js new file mode 100644 index 00000000..2a3b49f8 --- /dev/null +++ b/tests/integration/leaderboardRouter.test.js @@ -0,0 +1,31 @@ +const express = require('express') +const supertestSession = require("supertest-session"); +const fafApp = require('../../fafApp') +const passportMock = require('../helpers/PassportMock') + +let testSession = null +beforeEach(() => { + const app = new express() + fafApp.setup(app) + passportMock(app, {passAuthentication: true}) + fafApp.loadRouters(app) + testSession = supertestSession(app) +}) + +describe('Leaderboard Routes', function () { + + test('authentication required for main page', async () => { + let response = await testSession.get('/leaderboards') + expect(response.status).toBe(302) + + await testSession.get('/mock-login') + + response = await testSession.get('/leaderboards') + expect(response.status).toBe(200) + }) + + test('authentication required for datasets', async () => { + let response = await testSession.get('/leaderboards/1v1.json') + expect(response.status).toBe(401) + }) +}) diff --git a/tests/integration/testData/newshub.json b/tests/integration/testData/newshub.json new file mode 100644 index 00000000..66a32213 --- /dev/null +++ b/tests/integration/testData/newshub.json @@ -0,0 +1,89 @@ +[ + { + "category": [ + 283 + ], + "sortIndex": "1000", + "link": "https://github.com/FAForever/downlords-faf-client/releases", + "date": "2023-10-13T15:06:53", + "title": "Help with testing an alpha build of the client!", + "content": "\n

A new alpha build of the client is released. This client uses the new server communication protocol which should help to mitigate some of the connection issues people have been seeing recently. Do give it a try and report back on Discord using the #client-bug-reporting channel!

\n\n\n\n

Latest version is 2023.10.0-alpha-4, released on the 16th of October. This version also brings back the IRC chat

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/06/server_issues.png" + }, + { + "category": [ + 633, + 283 + ], + "sortIndex": "910", + "link": "https://www.youtube.com/watch?v=n2KbEQAzuN8&list=PLp2GJBSquXYfPhSiXoDJs8jKCUvZp3ADv&index=5", + "date": "2023-10-13T15:09:48", + "title": "7 Essential Tips for Beginners", + "content": "\n

TheGreenSquier goes over certain things you can do if you want to improve at Supreme Commander. Depending on your rating, you might already be doing them!

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/10/Get_Good_Thumbnail_1.png" + }, + { + "category": [ + 283, + 23 + ], + "sortIndex": "890", + "link": "https://forum.faforever.com/topic/6535/sunday-tournaments", + "date": "2023-09-27T18:29:12", + "title": "Weekly Sunday Tournaments!", + "content": "\n

Rowan is hosting a weekly, casual tournament every Sunday! The format changes each week. Click here to read up more about it on the forums

\n\n\n\n

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/09/tournament.png" + }, + { + "category": [ + 633, + 283 + ], + "sortIndex": "880", + "link": "https://forum.faforever.com/topic/6499/brigadier-fletcher-mapping-tournament/1", + "date": "2023-10-13T15:20:34", + "title": "‘Brigadier Fletcher’ Mapping tournament", + "content": "\n

Another tournament, another faction! This time the theme the UEF Faction starring Brigadier Fletcher! Everyone is welcome to participate. Deadline to submit is the 12th of November, 2023

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/10/4e1f7490-e117-4687-ba9a-b9f0f05bbe3b-image.png" + }, + { + "category": [ + 283 + ], + "sortIndex": "800", + "link": "https://wiki.faforever.com/en/Development/Mapping", + "date": "2023-09-09T15:57:08", + "title": "Modern mapping tutorials", + "content": "\n

Now’s the ideal time to master map-making! Thanks to Prohibitorum, FAForever Wiki’s mapping section is up-to-date with the latest tutorials.

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/09/luminarybreakdown.jpg" + }, + { + "category": [ + 283 + ], + "sortIndex": "750", + "link": "https://wiki.faforever.com/en/Development/Mapping/Gaea/Texturing", + "date": "2023-09-13T14:17:34", + "title": "Texturing guide using Gaea", + "content": "\n

Another valuable piece of content for map authors: Learn all about procedurally generating stratum masks! With thanks to Prohibitorum for his time and effort

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/09/preview-texturing-gaea.png" + }, + { + "category": [ + 283 + ], + "sortIndex": "500", + "link": "https://www.youtube.com/watch?v=4qItEqlOYmg", + "date": "2023-09-13T13:49:44", + "title": "Latest updates by Tactical Takeover", + "content": "\n

Learn about some of the biggest features added by the last update in this video showcase by Tactical Takeover!

\n", + "author": "Jip", + "media": "https://direct.faforever.xyz/wp-content/uploads/2023/09/tt-thumbnail.png" + } +]