mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-31 15:06:42 +00:00
Compare commits
37 Commits
1.16.2-s.1
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34879892b | ||
|
|
e345c6ee6e | ||
|
|
5cad07f8ad | ||
|
|
f9d872558e | ||
|
|
48013228c1 | ||
|
|
dbafffe73d | ||
|
|
61cbcb2a06 | ||
|
|
89c1ad5d98 | ||
|
|
b343ca6290 | ||
|
|
b913466671 | ||
|
|
9054f4f9c3 | ||
|
|
3915024d9a | ||
|
|
7d1085b43f | ||
|
|
7c2477cccc | ||
|
|
5aecb5fb90 | ||
|
|
f86d040ee4 | ||
|
|
ed32717b3f | ||
|
|
aab8462134 | ||
|
|
c20dfdabfb | ||
|
|
11a6f1f47f | ||
|
|
bdc45887f9 | ||
|
|
8e160902af | ||
|
|
06f840a680 | ||
|
|
5ddcfeb506 | ||
|
|
914e95e47f | ||
|
|
5b9efc3c5f | ||
|
|
6d7a19b0a0 | ||
|
|
6b3a6fa380 | ||
|
|
e2a65b4b74 | ||
|
|
1f01108b62 | ||
|
|
871f14ef3a | ||
|
|
1d5dfd6db2 | ||
|
|
ad3fe2fa76 | ||
|
|
863eb8efe9 | ||
|
|
5455d1c118 | ||
|
|
ae39084a75 | ||
|
|
27d20eb1bc |
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
@@ -415,7 +415,7 @@ jobs:
|
||||
|
||||
- name: Install cosign
|
||||
# cosign is used to sign and verify container images (key and keyless)
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||
|
||||
2
.github/workflows/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
skopeo --version
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Input check
|
||||
run: |
|
||||
|
||||
@@ -99,11 +99,6 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// findPattern finds the start of a pattern in a string
|
||||
func findPattern(s, pattern string) int {
|
||||
return bytes.Index([]byte(s), []byte(pattern))
|
||||
}
|
||||
|
||||
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||
// Read source file
|
||||
sourceData, err := os.ReadFile(sourceFile)
|
||||
@@ -187,7 +182,7 @@ func backupConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
|
||||
func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
|
||||
buffer := new(bytes.Buffer)
|
||||
encoder := yaml.NewEncoder(buffer)
|
||||
encoder.SetIndent(indent)
|
||||
@@ -196,7 +191,12 @@ func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer encoder.Close()
|
||||
defer func() {
|
||||
if cerr := encoder.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -81,11 +81,17 @@ entryPoints:
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http3:
|
||||
advertisedPort: 443
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
middlewares:
|
||||
- crowdsec@file
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
insecureSkipVerify: true
|
||||
|
||||
ping:
|
||||
entryPoint: "web"
|
||||
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
- 51820:51820/udp
|
||||
- 21820:21820/udp
|
||||
- 443:443
|
||||
- 443:443/udp # For http3 QUIC if desired
|
||||
- 80:80
|
||||
{{end}}
|
||||
traefik:
|
||||
|
||||
@@ -40,6 +40,8 @@ entryPoints:
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http3:
|
||||
advertisedPort: 443
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
|
||||
@@ -3,7 +3,7 @@ module installer
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -14,8 +14,8 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
|
||||
@@ -85,33 +85,6 @@ func readString(prompt string, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func readStringNoDefault(prompt string) string {
|
||||
var value string
|
||||
|
||||
for {
|
||||
input := huh.NewInput().
|
||||
Title(prompt).
|
||||
Value(&value).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("this field is required")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runField(input)
|
||||
handleAbort(err)
|
||||
|
||||
if value != "" {
|
||||
// Print the answer so it remains visible in terminal history
|
||||
if !isAccessibleMode() {
|
||||
fmt.Printf("%s: %s\n", prompt, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readPassword(prompt string) string {
|
||||
var value string
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -430,9 +429,9 @@ func createConfigFiles(config Config) error {
|
||||
}
|
||||
|
||||
// Walk through all embedded files
|
||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
// Skip the root fs directory itself
|
||||
@@ -483,7 +482,11 @@ func createConfigFiles(config Config) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer func() {
|
||||
if cerr := outFile.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
// Execute template
|
||||
if err := tmpl.Execute(outFile, config); err != nil {
|
||||
@@ -499,18 +502,26 @@ func createConfigFiles(config Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
func copyFile(src, dst string) (err error) {
|
||||
source, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
defer func() {
|
||||
if cerr := source.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
destination, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destination.Close()
|
||||
defer func() {
|
||||
if cerr := destination.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
return err
|
||||
@@ -622,32 +633,6 @@ func generateRandomSecretKey() string {
|
||||
return base64.StdEncoding.EncodeToString(secret)
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("https://ifconfig.io/ip")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body))
|
||||
|
||||
// Validate that it's a valid IP address
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run external commands with stdio/stderr attached.
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
|
||||
883
package-lock.json
generated
883
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -92,12 +92,12 @@
|
||||
"lucide-react": "0.577.0",
|
||||
"maxmind": "5.0.5",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.5.12",
|
||||
"next": "15.5.14",
|
||||
"next-intl": "4.8.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "8.0.1",
|
||||
"nodemailer": "8.0.4",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.20.0",
|
||||
"posthog-node": "5.28.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "2.15.4",
|
||||
"recharts": "3.8.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
@@ -125,7 +125,7 @@
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.19.0",
|
||||
"yaml": "2.8.2",
|
||||
"yaml": "2.8.3",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.3.6",
|
||||
"zod-validation-error": "5.0.0"
|
||||
@@ -134,7 +134,7 @@
|
||||
"@dotenvx/dotenvx": "1.54.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
@@ -160,21 +160,21 @@
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.27.3",
|
||||
"esbuild": "0.27.4",
|
||||
"esbuild-node-externals": "1.20.1",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"postcss": "8.5.8",
|
||||
"prettier": "3.8.1",
|
||||
"react-email": "5.2.10",
|
||||
"tailwindcss": "4.2.1",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.56.1"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.27.3",
|
||||
"esbuild": "0.27.4",
|
||||
"dompurify": "3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,8 @@ export const users = pgTable("user", {
|
||||
termsVersion: varchar("termsVersion"),
|
||||
marketingEmailConsent: boolean("marketingEmailConsent").default(false),
|
||||
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }),
|
||||
locale: varchar("locale")
|
||||
});
|
||||
|
||||
export const newts = pgTable("newt", {
|
||||
|
||||
@@ -322,7 +322,8 @@ export const users = sqliteTable("user", {
|
||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
lastPasswordChange: integer("lastPasswordChange")
|
||||
lastPasswordChange: integer("lastPasswordChange"),
|
||||
locale: text("locale")
|
||||
});
|
||||
|
||||
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||
|
||||
@@ -793,6 +793,11 @@ unauthenticated.get(
|
||||
// );
|
||||
|
||||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||
unauthenticated.post(
|
||||
"/user/locale",
|
||||
verifySessionMiddleware,
|
||||
user.updateUserLocale
|
||||
);
|
||||
unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
|
||||
|
||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { sites } from "@server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -31,7 +30,10 @@ const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 50;
|
||||
|
||||
// How often to flush accumulated bandwidth data to the database
|
||||
const FLUSH_INTERVAL_MS = 30_000; // 30 seconds
|
||||
const FLUSH_INTERVAL_MS = 300_000; // 300 seconds
|
||||
|
||||
// Maximum number of sites to include in a single batch UPDATE statement
|
||||
const BATCH_CHUNK_SIZE = 250;
|
||||
|
||||
// In-memory accumulator: publicKey -> AccumulatorEntry
|
||||
let accumulator = new Map<string, AccumulatorEntry>();
|
||||
@@ -75,13 +77,33 @@ async function withDeadlockRetry<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw SQL query that returns rows, in a way that works across both
|
||||
* the PostgreSQL driver (which exposes `execute`) and the SQLite driver (which
|
||||
* exposes `all`). Drizzle's typed query builder doesn't support bulk
|
||||
* UPDATE … FROM (VALUES …) natively, so we drop to raw SQL here.
|
||||
*/
|
||||
async function dbQueryRows<T extends Record<string, unknown>>(
|
||||
query: Parameters<(typeof sql)["join"]>[0][number]
|
||||
): Promise<T[]> {
|
||||
const anyDb = db as any;
|
||||
if (typeof anyDb.execute === "function") {
|
||||
// PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array
|
||||
const result = await anyDb.execute(query);
|
||||
return (Array.isArray(result) ? result : (result.rows ?? [])) as T[];
|
||||
}
|
||||
// SQLite (better-sqlite3 via Drizzle) — returns an array directly
|
||||
return (await anyDb.all(query)) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all accumulated site bandwidth data to the database.
|
||||
*
|
||||
* Swaps out the accumulator before writing so that any bandwidth messages
|
||||
* received during the flush are captured in the new accumulator rather than
|
||||
* being lost or causing contention. Entries that fail to write are re-queued
|
||||
* back into the accumulator so they will be retried on the next flush.
|
||||
* being lost or causing contention. Sites are updated in chunks via a single
|
||||
* batch UPDATE per chunk. Failed chunks are discarded — exact per-flush
|
||||
* accuracy is not critical and re-queuing is not worth the added complexity.
|
||||
*
|
||||
* This function is exported so that the application's graceful-shutdown
|
||||
* cleanup handler can call it before the process exits.
|
||||
@@ -108,76 +130,76 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
`Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database`
|
||||
);
|
||||
|
||||
// Aggregate billing usage by org, collected during the DB update loop.
|
||||
// Build a lookup so post-processing can reach each entry by publicKey.
|
||||
const snapshotMap = new Map(sortedEntries);
|
||||
|
||||
// Aggregate billing usage by org across all chunks.
|
||||
const orgUsageMap = new Map<string, number>();
|
||||
|
||||
for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) {
|
||||
// Process in chunks so individual queries stay at a reasonable size.
|
||||
for (let i = 0; i < sortedEntries.length; i += BATCH_CHUNK_SIZE) {
|
||||
const chunk = sortedEntries.slice(i, i + BATCH_CHUNK_SIZE);
|
||||
const chunkEnd = i + chunk.length - 1;
|
||||
|
||||
// Build a parameterised VALUES list: (pubKey, bytesIn, bytesOut), ...
|
||||
// Both PostgreSQL and SQLite (≥ 3.33.0, which better-sqlite3 bundles)
|
||||
// support UPDATE … FROM (VALUES …), letting us update the whole chunk
|
||||
// in a single query instead of N individual round-trips.
|
||||
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
|
||||
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
|
||||
);
|
||||
const valuesClause = sql.join(valuesList, sql`, `);
|
||||
|
||||
let rows: { orgId: string; pubKey: string }[] = [];
|
||||
|
||||
try {
|
||||
const updatedSite = await withDeadlockRetry(async () => {
|
||||
const [result] = await db
|
||||
.update(sites)
|
||||
.set({
|
||||
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`,
|
||||
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`,
|
||||
lastBandwidthUpdate: currentTime,
|
||||
})
|
||||
.where(eq(sites.pubKey, publicKey))
|
||||
.returning({
|
||||
orgId: sites.orgId,
|
||||
siteId: sites.siteId
|
||||
});
|
||||
return result;
|
||||
}, `flush bandwidth for site ${publicKey}`);
|
||||
|
||||
if (updatedSite) {
|
||||
if (exitNodeId) {
|
||||
const notAllowed = await checkExitNodeOrg(
|
||||
exitNodeId,
|
||||
updatedSite.orgId
|
||||
);
|
||||
if (notAllowed) {
|
||||
logger.warn(
|
||||
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
||||
);
|
||||
// Skip usage tracking for this site but continue
|
||||
// processing the rest.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (calcUsage) {
|
||||
const totalBandwidth = bytesIn + bytesOut;
|
||||
const current = orgUsageMap.get(updatedSite.orgId) ?? 0;
|
||||
orgUsageMap.set(updatedSite.orgId, current + totalBandwidth);
|
||||
}
|
||||
}
|
||||
rows = await withDeadlockRetry(async () => {
|
||||
return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
|
||||
UPDATE sites
|
||||
SET
|
||||
"bytesOut" = COALESCE("bytesOut", 0) + v.bytes_in,
|
||||
"bytesIn" = COALESCE("bytesIn", 0) + v.bytes_out,
|
||||
"lastBandwidthUpdate" = ${currentTime}
|
||||
FROM (VALUES ${valuesClause}) AS v(pub_key, bytes_in, bytes_out)
|
||||
WHERE sites."pubKey" = v.pub_key
|
||||
RETURNING sites."orgId" AS "orgId", sites."pubKey" AS "pubKey"
|
||||
`);
|
||||
}, `flush bandwidth chunk [${i}–${chunkEnd}]`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to flush bandwidth for site ${publicKey}:`,
|
||||
`Failed to flush bandwidth chunk [${i}–${chunkEnd}], discarding ${chunk.length} site(s):`,
|
||||
error
|
||||
);
|
||||
// Discard the chunk — exact per-flush accuracy is not critical.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-queue the failed entry so it is retried on the next flush
|
||||
// rather than silently dropped.
|
||||
const existing = accumulator.get(publicKey);
|
||||
if (existing) {
|
||||
existing.bytesIn += bytesIn;
|
||||
existing.bytesOut += bytesOut;
|
||||
} else {
|
||||
accumulator.set(publicKey, {
|
||||
bytesIn,
|
||||
bytesOut,
|
||||
exitNodeId,
|
||||
calcUsage
|
||||
});
|
||||
// Collect billing usage from the returned rows.
|
||||
for (const { orgId, pubKey } of rows) {
|
||||
const entry = snapshotMap.get(pubKey);
|
||||
if (!entry) continue;
|
||||
|
||||
const { bytesIn, bytesOut, exitNodeId, calcUsage } = entry;
|
||||
|
||||
if (exitNodeId) {
|
||||
const notAllowed = await checkExitNodeOrg(exitNodeId, orgId);
|
||||
if (notAllowed) {
|
||||
logger.warn(
|
||||
`Exit node ${exitNodeId} is not allowed for org ${orgId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (calcUsage) {
|
||||
const current = orgUsageMap.get(orgId) ?? 0;
|
||||
orgUsageMap.set(orgId, current + bytesIn + bytesOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process billing usage updates outside the site-update loop to keep
|
||||
// lock scope small and concerns separated.
|
||||
// Process billing usage updates after all chunks are written.
|
||||
if (orgUsageMap.size > 0) {
|
||||
// Sort org IDs for consistent lock ordering.
|
||||
const sortedOrgIds = [...orgUsageMap.keys()].sort();
|
||||
|
||||
for (const orgId of sortedOrgIds) {
|
||||
|
||||
@@ -8,13 +8,6 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
const inputSchema = z.object({
|
||||
publicKey: z.string(),
|
||||
port: z.int().positive()
|
||||
});
|
||||
|
||||
type Input = z.infer<typeof inputSchema>;
|
||||
|
||||
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
@@ -33,16 +26,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = inputSchema.safeParse(message.data);
|
||||
if (!parsed.success) {
|
||||
logger.error(
|
||||
"handleGetConfigMessage: Invalid input: " +
|
||||
fromError(parsed.error).toString()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { publicKey, port } = message.data as Input;
|
||||
const { publicKey, port, chainId } = message.data;
|
||||
const siteId = newt.siteId;
|
||||
|
||||
// Get the current site data
|
||||
@@ -133,7 +117,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
data: {
|
||||
ipAddress: site.address,
|
||||
peers,
|
||||
targets
|
||||
targets,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noCloud } = message.data;
|
||||
const { noCloud, chainId } = message.data;
|
||||
|
||||
const exitNodesList = await listExitNodes(
|
||||
site.orgId,
|
||||
@@ -98,7 +98,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
||||
message: {
|
||||
type: "newt/ping/exitNodes",
|
||||
data: {
|
||||
exitNodes: filteredExitNodes
|
||||
exitNodes: filteredExitNodes,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
|
||||
@@ -43,7 +43,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
|
||||
const siteId = newt.siteId;
|
||||
|
||||
const { publicKey, pingResults, newtVersion, backwardsCompatible } =
|
||||
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
|
||||
message.data;
|
||||
if (!publicKey) {
|
||||
logger.warn("Public key not provided");
|
||||
@@ -211,7 +211,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
udp: udpTargets,
|
||||
tcp: tcpTargets
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets
|
||||
healthCheckTargets: validHealthCheckTargets,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -41,7 +41,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
orgId,
|
||||
userToken,
|
||||
fingerprint,
|
||||
postures
|
||||
postures,
|
||||
chainId
|
||||
} = message.data;
|
||||
|
||||
if (!olm.clientId) {
|
||||
@@ -293,7 +294,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
data: {
|
||||
sites: siteConfigurations,
|
||||
tunnelIP: client.subnet,
|
||||
utilitySubnet: org.utilitySubnet
|
||||
utilitySubnet: org.utilitySubnet,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -20,7 +20,8 @@ async function queryUser(userId: string) {
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
@@ -16,4 +16,5 @@ export * from "./createOrgUser";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./updateOrgUser";
|
||||
export * from "./updateUserLocale";
|
||||
export * from "./myDevice";
|
||||
|
||||
@@ -63,7 +63,8 @@ export async function myDevice(
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
57
server/routers/user/updateUserLocale.ts
Normal file
57
server/routers/user/updateUserLocale.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
locale: z.string().min(2).max(10)
|
||||
});
|
||||
|
||||
export async function updateUserLocale(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not found")
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { locale } = parsedBody.data;
|
||||
|
||||
await db.update(users).set({ locale }).where(eq(users.userId, userId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User locale updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import clsx from "clsx";
|
||||
import { useTransition } from "react";
|
||||
import { Locale } from "@/i18n/config";
|
||||
import { setUserLocale } from "@/services/locale";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type Props = {
|
||||
defaultValue: string;
|
||||
@@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({
|
||||
label
|
||||
}: Props) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
function onChange(value: string) {
|
||||
const locale = value as Locale;
|
||||
startTransition(() => {
|
||||
setUserLocale(locale);
|
||||
});
|
||||
// Persist locale to the database (fire-and-forget)
|
||||
api.post("/user/locale", { locale }).catch(() => {
|
||||
// Silently ignore errors — cookie is already set as fallback
|
||||
});
|
||||
}
|
||||
|
||||
const selected = items.find((item) => item.value === defaultValue);
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Locale, defaultLocale, locales } from "@/i18n/config";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
||||
// In this example the locale is read from a cookie. You could alternatively
|
||||
// also read it from a database, backend service, or any other source.
|
||||
const COOKIE_NAME = "NEXT_LOCALE";
|
||||
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds
|
||||
|
||||
export async function getUserLocale(): Promise<Locale> {
|
||||
const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value;
|
||||
@@ -14,6 +17,23 @@ export async function getUserLocale(): Promise<Locale> {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
|
||||
// No cookie found — try to restore from user's saved locale in DB
|
||||
try {
|
||||
const res = await internal.get("/user", await authCookieHeader());
|
||||
const userLocale = res.data?.data?.locale;
|
||||
if (userLocale && locales.includes(userLocale as Locale)) {
|
||||
// Set the cookie so subsequent requests don't need the API call
|
||||
(await cookies()).set(COOKIE_NAME, userLocale, {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax"
|
||||
});
|
||||
return userLocale as Locale;
|
||||
}
|
||||
} catch {
|
||||
// User not logged in or API unavailable — fall through
|
||||
}
|
||||
|
||||
const headerList = await headers();
|
||||
const acceptLang = headerList.get("accept-language");
|
||||
|
||||
@@ -33,5 +53,9 @@ export async function getUserLocale(): Promise<Locale> {
|
||||
}
|
||||
|
||||
export async function setUserLocale(locale: Locale) {
|
||||
(await cookies()).set(COOKIE_NAME, locale);
|
||||
(await cookies()).set(COOKIE_NAME, locale, {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax"
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user