Merge pull request #49 from LINUXexpert-org/agpl-source-offer

Make the AGPL's source offer point at the source being run
This commit is contained in:
LINUXexpert.org
2026-08-25 13:47:41 -07:00
committed by GitHub
11 changed files with 54 additions and 2 deletions
+6
View File
@@ -39,3 +39,9 @@ IMAGE_PROXY=1
# Branding # Branding
APP_NAME=ihasmail APP_NAME=ihasmail
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
# asks whoever runs a modified version to offer *that* version's source -- so if
# you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About.
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
+5
View File
@@ -233,3 +233,8 @@ nearly always run as a network service rather than handed to anyone as a
binary, and the AGPL's section 13 closes that gap: anyone running a modified binary, and the AGPL's section 13 closes that gap: anyone running a modified
ihasmail for other people has to offer them its source, which the GPL alone ihasmail for other people has to offer them its source, which the GPL alone
does not require. does not require.
That offer has to point at *your* source, not this one. If you run a modified
ihasmail, set `SOURCE_URL` to your own repository: the sign-in page and
Settings About both show it, so the people using your instance are told where
the code they are actually running can be found.
+1
View File
@@ -9,6 +9,7 @@ services:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env} STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail} APP_NAME: ${APP_NAME:-ihasmail}
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
TRUST_PROXY: "1" TRUST_PROXY: "1"
IMAGE_PROXY: "1" IMAGE_PROXY: "1"
volumes: volumes:
+1
View File
@@ -2,6 +2,7 @@
"name": "@ihasmail/server", "name": "@ihasmail/server",
"version": "2.0.0", "version": "2.0.0",
"private": true, "private": true,
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+2
View File
@@ -148,6 +148,7 @@ export function createApp(): Hono<Env> {
api.get("/config", (c) => api.get("/config", (c) =>
c.json({ c.json({
appName: config.appName, appName: config.appName,
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy, imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes, maxUploadBytes: config.maxUploadBytes,
}), }),
@@ -581,6 +582,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
return { return {
ihasmail: { ihasmail: {
appName: config.appName, appName: config.appName,
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy, imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes, maxUploadBytes: config.maxUploadBytes,
sessionId: session.id, sessionId: session.id,
+8
View File
@@ -60,6 +60,14 @@ const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+
export const config = { export const config = {
isProd, isProd,
appName: env("APP_NAME", "ihasmail"), appName: env("APP_NAME", "ihasmail"),
/**
* Where this instance's source can be had, shown to everyone who reaches it.
*
* The AGPL asks whoever *runs* a modified version to offer that version's
* source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
stalwartUrl, stalwartUrl,
+1
View File
@@ -2,6 +2,7 @@
"name": "@ihasmail/web", "name": "@ihasmail/web",
"version": "2.0.0", "version": "2.0.0",
"private": true, "private": true,
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -25,6 +25,8 @@ export interface JmapSession {
state: string; state: string;
ihasmail?: { ihasmail?: {
appName: string; appName: string;
/** Where this instance's source can be had, for the AGPL's sake. */
sourceUrl?: string;
imageProxy: boolean; imageProxy: boolean;
maxUploadBytes: number; maxUploadBytes: number;
sessionId: string; sessionId: string;
+8
View File
@@ -0,0 +1,8 @@
/**
* Where to point someone who wants this instance's source.
*
* The AGPL asks whoever runs a modified version to offer *that* version's
* source. The server says where its own lives, via SOURCE_URL; this is only the
* fallback for when it has not been asked yet, or has nothing to say.
*/
export const DEFAULT_SOURCE_URL = "https://github.com/LINUXexpert-org/ihasmail";
+16 -1
View File
@@ -1,10 +1,23 @@
import { useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react"; import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client"; import { ApiError } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
export function LoginPage() { export function LoginPage() {
const login = useSession((s) => s.login); const login = useSession((s) => s.login);
// The AGPL's offer has to reach everyone who interacts with the app over the
// network, and that includes whoever is looking at this form. The server says
// where its own source lives, so a modified deployment points at its own.
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
useEffect(() => {
let live = true;
fetch("/api/config")
.then((r) => (r.ok ? r.json() : null))
.then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); })
.catch(() => { /* the default stands */ });
return () => { live = false; };
}, []);
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? ""); const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [totp, setTotp] = useState(""); const [totp, setTotp] = useState("");
@@ -81,6 +94,8 @@ export function LoginPage() {
</button> </button>
<p className="foot"> <p className="foot">
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a> ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
</p> </p>
</form> </form>
</div> </div>
+4 -1
View File
@@ -1,9 +1,12 @@
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
export function AboutSettings() { export function AboutSettings() {
const session = useSession((s) => s.session); const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {}); const caps = Object.keys(session?.capabilities ?? {});
// A deployment running modified code should offer its own source, not ours.
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return ( return (
<div> <div>
<h1>About ihasmail</h1> <h1>About ihasmail</h1>
@@ -12,7 +15,7 @@ export function AboutSettings() {
<img src="/img/logo.png" alt="ihasmail" width={96} /> <img src="/img/logo.png" alt="ihasmail" width={96} />
<div> <div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div> <div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
<div className="hint">AGPL-3.0-or-later · <a href="https://github.com/LINUXexpert-org/ihasmail" target="_blank" rel="noreferrer">github.com/LINUXexpert-org/ihasmail</a></div> <div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
</div> </div>
</div> </div>
<h2>Server</h2> <h2>Server</h2>