Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2ab03a2
Bump ip-address from 10.0.1 to 10.4.0
dependabot[bot] Aug 4, 2026
53608d3
Bump fast-uri from 3.1.2 to 3.1.5 in /packages/api
dependabot[bot] Aug 5, 2026
9a36782
Merge pull request #4303 from LiteFarmOrg/main
kathyavini Aug 5, 2026
3568a3e
LF-5416 Add a 30-second single-use dashboard ticket endpoint
kathyavini Aug 5, 2026
04945b2
LF-5416 Clean up comments
kathyavini Aug 6, 2026
a46fd54
Merge pull request #4304 from LiteFarmOrg/LF-5416-add-a-30-second-sin…
SayakaOno Aug 6, 2026
274178c
LF-5417 Add the dashboard ticket exchange endpoint
kathyavini Aug 6, 2026
c96060a
LF-5417 Cleanup
kathyavini Aug 6, 2026
335fe70
LF-5417 Rename boolean in controller for clarity
kathyavini Aug 7, 2026
1d97aae
Merge pull request #4305 from LiteFarmOrg/LF-5417-add-the-endpoint-th…
kathyavini Aug 7, 2026
1758ee3
LF-5418 Read the dashboard return address and build the ticket URL
kathyavini Aug 7, 2026
d54fe65
LF-5418 Request a dashboard ticket from a shared saga generator
kathyavini Aug 7, 2026
434ae2d
LF-5418 Hand off to the Dashboard after password, new-account and Goo…
kathyavini Aug 7, 2026
7efe8f5
LF-5418 Hand off to the Dashboard when a LiteFarm session is already …
kathyavini Aug 7, 2026
6411dc7
LF-5418 Request the dashboard ticket through RTK Query
kathyavini Aug 8, 2026
b423c04
LF-5418 Adjust PureCustomSignUp visuals when dashboardReturnTo is pre…
kathyavini Aug 10, 2026
f55274d
LF-4982 Add missing gzip_types in nginx.conf
SayakaOno Oct 10, 2025
4307a43
LF-4982 Update proxy_set_header in nginx.conf
SayakaOno Oct 10, 2025
55afabb
LF-4982 Enable trust proxy
SayakaOno Oct 10, 2025
74c0dce
LF-5418 Replace dashboardTicketSlice with module variable
kathyavini Aug 10, 2026
75a72b8
LF-5418 Logout but preserve address when receiving 401
kathyavini Aug 10, 2026
9330822
LF-5418 Clean up most offensive AI comments
kathyavini Aug 10, 2026
f9586ec
LF-5418 Update snackbar messaging; I was super bothered by an error o…
kathyavini Aug 10, 2026
f615481
Merge pull request #4306 from LiteFarmOrg/LF-5418-send-the-user-to-th…
SayakaOno Aug 10, 2026
bd0b88c
LF-5443 Add @testing-library/react and widen the vitest include glob
kathyavini Aug 11, 2026
c446536
LF-5443 Add useAuthenticatedSession
kathyavini Aug 11, 2026
2c71950
LF-5443 Gate routing on a usable session rather than a token
kathyavini Aug 11, 2026
5fa2f38
LF-5443 Scope the user farms lookup to the caller
kathyavini Aug 11, 2026
a14417c
LF-5443 Return from getUserFarmsSaga when there is no identity
kathyavini Aug 11, 2026
99669be
LF-5443 Don't logout (i.e. remove id_token) in situation of mismatch
kathyavini Aug 12, 2026
9d16bb0
LF-4982 Update gzip config in nginx.conf
SayakaOno Aug 12, 2026
d60a8d7
LF-4982 Correct link to express doc
SayakaOno Aug 12, 2026
79d2e30
LF-4982 Trust proxy by hop count instead of unconditionally
SayakaOno Aug 12, 2026
71bfa91
LF-4982 Update gzip_min_length to 1KB
SayakaOno Aug 12, 2026
27c2fb2
Merge pull request #4307 from LiteFarmOrg/LF-4982/Improve_nginx.conf
kathyavini Aug 12, 2026
6d4314d
Merge pull request #4308 from LiteFarmOrg/LF-5443-signing-in-while-a-…
SayakaOno Aug 12, 2026
29d891d
Update version to 3.13.1
SayakaOno Aug 12, 2026
2223c9e
Merge pull request #4295 from LiteFarmOrg/dependabot/npm_and_yarn/ip-…
SayakaOno Aug 12, 2026
37ac009
Merge pull request #4298 from LiteFarmOrg/dependabot/npm_and_yarn/pac…
SayakaOno Aug 12, 2026
dd03940
New Crowdin translations by GitHub Action
crowdin-bot Aug 13, 2026
56fcb2e
Clean unused translations
kathyavini Aug 13, 2026
c49d7aa
Merge pull request #4310 from LiteFarmOrg/l10n_crowdin_translations_i…
kathyavini Aug 13, 2026
f71ead6
Merge pull request #4309 from LiteFarmOrg/version-bump-3.13.1
kathyavini Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/automated_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
JWT_RESET_SECRET: ${{secrets.JWT_RESET_SECRET}}
JWT_SCHEDULER_SECRET: ${{secrets.JWT_SCHEDULER_SECRET}}
JWT_SECRET: ${{secrets.JWT_SECRET}}
JWT_DASHBOARD_SECRET: Dashboard_tickets_get_their_own_secret
TEST_USER: ${{secrets.TEST_USER}}
TEST_USER_ID: ${{secrets.TEST_USER_ID}}
steps:
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/api/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ JWT_INVITE_SECRET=Any_arbitrary_string_will_do
JWT_RESET_SECRET=Production_is_secured_with_a_long_random_string
JWT_FARM_SECRET=Here_we_can_use_friendly_explanations
JWT_SCHEDULER_SECRET=Another_token_was_needed_for_the_scheduler
JWT_DASHBOARD_SECRET=Dashboard_tickets_get_their_own_secret

# Comma-separated exact addresses an Analytics Dashboard ticket may be returned to.
# Contact the Analytics Dashboard team for the value for your environment.
DASHBOARD_ALLOWED_RETURN_TO=?

# Create your own (free in most cases) Google API key at https://console.cloud.google.com/apis/dashboard
# (Optional) We use google-maps-services-js package so API services on this key can be restricted to APIs listed here:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2026 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LiteFarm is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
*/

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async function (knex) {
await knex.schema.createTable('dashboard_ticket_use', (table) => {
table.text('jti').primary();
table.timestamp('used_at').notNullable().defaultTo(knex.fn.now());
});
};

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = async function (knex) {
await knex.schema.dropTable('dashboard_ticket_use');
};
16 changes: 8 additions & 8 deletions packages/api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "litefarm-api",
"version": "3.13.0",
"version": "3.13.1",
"description": "LiteFarm API server",
"main": "./api/src/server.js",
"type": "module",
Expand Down
126 changes: 118 additions & 8 deletions packages/api/src/controllers/loginController.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import parser from 'ua-parser-js';
import UserLogModel from '../models/userLogModel.js';
import EmailModel from '../models/emailTokenModel.js';
import { createToken } from '../util/jwt.js';
import { randomUUID } from 'crypto';
import jwt from 'jsonwebtoken';
import knex from '../util/knex.js';

const loginController = {
authenticateUser() {
Expand All @@ -39,14 +42,7 @@ const loginController = {
const ua = parser(req.headers['user-agent']);
const languages = req.acceptsLanguages();
let userID;

let ip = req.headers['x-forwarded-for'];
if (ip) {
const list = ip.split(',');
ip = list[list.length - 1];
} else {
ip = req.connection.remoteAddress;
}
const { ip } = req;

try {
const userData = await UserModel.query().select('*').where('email', email).first();
Expand Down Expand Up @@ -242,6 +238,120 @@ const loginController = {
}
};
},

dashboardIssueTicket() {
return async (req, res) => {
try {
const { user_id } = req.auth;
const { return_to, farm_id } = req.body;

const allowedReturnAddresses = (process.env.DASHBOARD_ALLOWED_RETURN_TO ?? '')
.split(',')
.map((address) => address.trim())
// Without this, an unset variable produces [''] and return_to: '' is allowed
.filter(Boolean);

// Exact string match: a prefix, suffix or substring of an allowed address is not a match.
if (!allowedReturnAddresses.includes(return_to)) {
return res.status(400).send({ message: 'return_to is not an allowed address.' });
}

if (farm_id) {
const userFarm = await UserFarmModel.query()
.where({ user_id, farm_id, status: 'Active' })
.first();
if (!userFarm) {
return res.sendStatus(403);
}
}

const ticket = await createToken('dashboard', {
user_id,
farm_id: farm_id ?? null,
jti: randomUUID(),
});

return res.status(200).send({ ticket, return_to });
} catch (error) {
console.error(error);
return res.status(500).json({ error });
}
};
},

dashboardExchange() {
return async (req, res) => {
try {
const { ticket } = req.body;

if (!ticket) {
return res.status(400).send({ message: 'ticket is required.' });
}

let payload;
try {
payload = jwt.verify(ticket, process.env.JWT_DASHBOARD_SECRET, {
algorithms: ['HS256'],
});
} catch (_verificationError) {
return res.sendStatus(401);
}

const { jti, user_id, farm_id } = payload;
if (!jti || !user_id) {
return res.sendStatus(401);
}

const claimSucceeded = await knex.transaction(async (trx) => {
await trx('dashboard_ticket_use')
.whereRaw("used_at < now() - interval '5 minutes'")
.del();

const inserted = await trx('dashboard_ticket_use')
.insert({ jti })
.onConflict('jti')
.ignore()
.returning('jti');

// False when this ticket has already been exchanged
return inserted.length > 0;
});

if (!claimSucceeded) {
return res.sendStatus(401);
}

const user = await UserModel.query()
.select('user_id', 'email', 'first_name')
.findById(user_id);
if (!user) {
return res.sendStatus(401);
}

const farms = await UserFarmModel.query()
.select('userFarm.farm_id', 'farm.farm_name', 'userFarm.role_id')
.join('farm', 'userFarm.farm_id', 'farm.farm_id')
.where('userFarm.user_id', user_id)
.andWhere('userFarm.status', 'Active')
.andWhere('farm.deleted', false);

if (farm_id && !farms.some((farm) => farm.farm_id === farm_id)) {
return res.sendStatus(401);
}

return res.status(200).send({
user_id: user.user_id,
email: user.email,
first_name: user.first_name,
farm_id: farm_id ?? null,
farms,
});
} catch (error) {
console.error(error);
return res.status(500).json({ error });
}
};
},
};

async function sendMissingInvitations(user) {
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/controllers/userLogController.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const userLogController = {
const { user_id } = req.auth;
const { screen_width, screen_height, farm_id } = req.body;
try {
const ip = req.headers['x-forwarded-for']?.split(',').shift() || req.socket?.remoteAddress;
const { ip } = req;
const ua = parser(req.headers['user-agent']);
const languages = req.acceptsLanguages();
await UserLogModel.query().insert({
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/routes/loginRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import express from 'express';
const router = express.Router();
import loginController from '../controllers/loginController.js';
import checkGoogleJwt from '../middleware/acl/checkGoogleJwt.js';
import checkJwt from '../middleware/acl/checkJwt.js';

router.post('/google', checkGoogleJwt, loginController.loginWithGoogle());
router.post('/', loginController.authenticateUser());
router.get('/user/:email', loginController.getUserNameByUserEmail());
// This router is mounted before the global checkJwt in server.ts,
// so the middleware is attached here to make the endpoint require a login token
router.post('/dashboard/ticket', checkJwt, loginController.dashboardIssueTicket());
router.post('/dashboard/exchange', loginController.dashboardExchange());

export default router;
3 changes: 1 addition & 2 deletions packages/api/src/routes/userFarmRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ import checkInvitationTokenContent from '../middleware/acl/checkInviteTokenConte
import checkUserFarmStatus from '../middleware/acl/checkUserFarmStatus.js';

// Get all userFarms for a specified user
// no permission limits
router.get('/user/:user_id', userFarmController.getUserFarmByUserID());
router.get('/user/:user_id', isSelf, userFarmController.getUserFarmByUserID());

// Get info on all users (userFarm) at a farm
router.get(
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ if (process.env.SENTRY_DSN && environment !== 'development') {
// Automatically instrument Node.js libraries and frameworks
...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations(),
],
release: '3.13.0',
release: '3.13.1',
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
Expand Down Expand Up @@ -275,6 +275,10 @@ app
.use(express.urlencoded({ extended: true }))
.disable('x-powered-by')

// https://expressjs.com/en/guide/behind-proxies/
// 1: trust exactly one hop (nginx) so a client-supplied X-Forwarded-For entry can't spoof req.ip
.set('trust proxy', 1)

// prevent CORS errors
.use(cors())
.use((req, res, next) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/util/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@ import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRES_IN = '7d';
const RESET_PASSWORD_TOKEN_EXPIRES_IN = '1d';
const SCHEDULER_TOKEN_EXPIRES_IN = '1d';
const DASHBOARD_TICKET_EXPIRES_IN = '30s';

const tokenType = {
access: process.env.JWT_SECRET,
invite: process.env.JWT_INVITE_SECRET,
passwordReset: process.env.JWT_RESET_SECRET,
farm: process.env.JWT_FARM_SECRET,
scheduler: process.env.JWT_SCHEDULER_SECRET,
dashboard: process.env.JWT_DASHBOARD_SECRET,
};
const expireTime = {
access: ACCESS_TOKEN_EXPIRES_IN,
invite: ACCESS_TOKEN_EXPIRES_IN,
passwordReset: RESET_PASSWORD_TOKEN_EXPIRES_IN,
farm: ACCESS_TOKEN_EXPIRES_IN,
scheduler: SCHEDULER_TOKEN_EXPIRES_IN,
dashboard: DASHBOARD_TICKET_EXPIRES_IN,
};

function createToken(type, payload) {
Expand Down
Loading
Loading