Compare commits

...

17 commits
main ... prod

Author SHA1 Message Date
D. Scott Boggs 4d215052e0 Remove extraneous debug code 2025-05-12 07:44:24 -04:00
D. Scott Boggs a90453eefc Set production app link 2025-05-12 07:22:58 -04:00
D. Scott Boggs 83ea4afbdf add prettier config 2025-05-12 07:22:47 -04:00
D. Scott Boggs 93b7bfffea Dynamically determine application href hostname for self-hosted instances 2025-05-12 07:22:35 -04:00
D. Scott Boggs ffc43e9071 Remove vercel analytics 2025-05-08 11:37:29 -04:00
D. Scott Boggs 03ee678059 $NEXT_PUBLIC_API_URL must be set at build time 2025-05-08 11:37:27 -04:00
D. Scott Boggs 9266599b6a Update theming and about content 2025-05-08 11:17:41 -04:00
D. Scott Boggs 39903bc9f1 remove redundant parentheses 2025-05-08 08:53:13 -04:00
D. Scott Boggs 8cb7b672d6 Repo is deployment dir
Rather than putting the source repo in a build dir and having a mount dir next to that, the
source repo itself is the deployment dir, with the mount dir inside of it. We do not want
to commit the contents of the database to the repo obviously
2025-05-08 08:52:01 -04:00
D. Scott Boggs a94bc545aa Fix Next config to match example Dockerfile expectations 2025-05-08 08:50:30 -04:00
D. Scott Boggs 94e700d2e7 UID/GID 1000 was in use 2025-05-08 08:49:36 -04:00
D. Scott Boggs c3cdc0073c Password files must be trimmed of newlines 2025-05-08 08:49:02 -04:00
D. Scott Boggs 3debc5609a Updated dependencies require a newer version of libssl; use a newer debian 2025-05-08 08:48:29 -04:00
D. Scott Boggs 1fede2034e Fix volume spec syntax error 2025-05-08 08:47:47 -04:00
D. Scott Boggs ef79078b06 Fix networks 2025-05-08 08:47:13 -04:00
D. Scott Boggs 76fb9b22ff Secrets can't have spaces 2025-05-08 08:46:32 -04:00
D. Scott Boggs 261b8cbed7 Add docker-compose file 2025-05-07 10:10:41 -04:00
17 changed files with 115 additions and 26 deletions

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
/graphics /graphics
.DS_Store .DS_Store
**/*.secret
**/*.pw **/*.pw
**/*.secret
mounts/

View file

@ -16,7 +16,13 @@ RUN --mount=type=cache,target=/usr/local/cargo,from=rust:latest,source=/usr/loca
cargo build --release --features $adaptor && mv ./target/release/crabfit-api ./api cargo build --release --features $adaptor && mv ./target/release/crabfit-api ./api
# Runtime image # Runtime image
FROM debian:bullseye-slim FROM debian:bookworm-slim
# install libssl3
RUN apt-get update &&\
apt-get install -yq libssl3 &&\
apt-get clean &&\
rm -rf /var/cache/apt/lists/*
# Run as "app" user # Run as "app" user
RUN useradd -ms /bin/bash app RUN useradd -ms /bin/bash app

View file

@ -188,11 +188,14 @@ fn get_connection_string() -> String {
if let Some(password_file_location) = env::var_os("DATABASE_PASSWORD_FILE") { if let Some(password_file_location) = env::var_os("DATABASE_PASSWORD_FILE") {
// The password can be left out of the URL, we add it from the specified // The password can be left out of the URL, we add it from the specified
// file (presumably under /run/secrets/) // file (presumably under /run/secrets/)
let password = fs::read(&password_file_location).unwrap_or_else(|err| { let password = fs::read(&password_file_location)
panic!("could not read database password from {password_file_location:?}\n\t{err:?}") .unwrap_or_else(|err| {
}); panic!("could not read database password from {password_file_location:?}\n\t{err:?}")
});
let password = String::from(String::from_utf8_lossy(password.as_slice()));
let password = password.trim_end();
let mut url = Url::parse(&connection_string).expect("invalid connection string"); let mut url = Url::parse(&connection_string).expect("invalid connection string");
url.set_password(Some(String::from_utf8_lossy(password.as_slice()).as_ref())) url.set_password(Some(password))
.unwrap_or_else(|_| panic!("invalid database URL: {connection_string:?}")); .unwrap_or_else(|_| panic!("invalid database URL: {connection_string:?}"));
url.to_string() url.to_string()
} else { } else {

View file

@ -35,7 +35,7 @@ pub async fn cleanup<A: Adaptor>(
println!("Error reading CRON_KEY_FILE at {path:?}"); println!("Error reading CRON_KEY_FILE at {path:?}");
return Err(ApiError::NotAuthorized); return Err(ApiError::NotAuthorized);
}; };
String::from_utf8_lossy(key.as_slice()).into() String::from_utf8_lossy(key.as_slice()).to_owned().trim_end().to_string()
} else { } else {
Default::default() Default::default()
}; };

60
docker-compose.yml Normal file
View file

@ -0,0 +1,60 @@
services:
crabfit-api:
build:
context: ./api
# args:
# adaptor: sql-adaptor (default) | memory-adaptor | datastore-adaptor
# # datastore is for Google Datastore
secrets:
- crabfit-database-password
- crabfit-cron-key
environment:
DATABASE_PASSWORD_FILE: /run/secrets/crabfit-database-password
DATABASE_URL: postgresql://crabfit@crabfit-database:5432/crabfit
FRONTEND_URL: https://availability.techwork.zone
CRON_KEY_FILE: /run/secrets/crabfit-cron-key
labels:
traefik.enable: true
traefik.http.routers.crabfit-api.rule: Host(`api.a10y.techwork.zone`)
traefik.http.routers.crabfit-api.tls: true
traefik.http.routers.crabfit-api.tls.certresolver: letsencrypt_standalone
networks:
- crabfit
- public
crabfit-database:
image: postgres:17
secrets: [ 'crabfit-database-password' ]
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/crabfit-database-password
POSTGRES_USER: crabfit
POSTGRES_DB: crabfit
volumes:
- ./mounts/database:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready"]
interval: 30s
timeout: 20s
retries: 3
networks: [ crabfit ]
crabfit-frontend:
build:
context: ./frontend
labels:
traefik.enable: true
traefik.http.routers.crabfit-frontend.rule: Host(`a10y.techwork.zone`) || Host(`availability.techwork.zone`)
traefik.http.routers.crabfit-frontend.tls: true
traefik.http.routers.crabfit-frontend.tls.certresolver: letsencrypt_standalone
networks: [ public ]
networks:
crabfit:
internal: true
secrets:
crabfit-database-password:
file: ./postgres.pw
crabfit-cron-key:
file: ./cron.secret

View file

@ -1,4 +1,5 @@
NEXT_PUBLIC_API_URL="http://127.0.0.1:3000" NEXT_PUBLIC_API_URL="https://api.a10y.techwork.zone"
NEXT_PUBLIC_HOSTNAME=availability.techwork.zone
# Google auth for calendar syncing, feature will be disabled if these aren't set # Google auth for calendar syncing, feature will be disabled if these aren't set
# NEXT_PUBLIC_GOOGLE_CLIENT_ID="" # NEXT_PUBLIC_GOOGLE_CLIENT_ID=""

View file

@ -33,8 +33,8 @@ ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime. # Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1000 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1000 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public

4
frontend/next.config.js Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
module.exports = {
output: 'standalone'
}

View file

@ -15,7 +15,6 @@
"@giraugh/tools": "^1.6.0", "@giraugh/tools": "^1.6.0",
"@js-temporal/polyfill": "^0.4.4", "@js-temporal/polyfill": "^0.4.4",
"@microsoft/microsoft-graph-client": "^3.0.5", "@microsoft/microsoft-graph-client": "^3.0.5",
"@vercel/analytics": "^1.0.1",
"accept-language": "^3.0.18", "accept-language": "^3.0.18",
"chroma.ts": "^1.0.10", "chroma.ts": "^1.0.10",
"hue-map": "^1.0.0", "hue-map": "^1.0.0",
@ -47,5 +46,10 @@
"sass": "^1.63.4", "sass": "^1.63.4",
"typescript": "^5.1.3", "typescript": "^5.1.3",
"typescript-plugin-css-modules": "^5.0.1" "typescript-plugin-css-modules": "^5.0.1"
},
"prettier": {
"semi": false,
"arrowParens": "avoid",
"singleQuote": true
} }
} }

View file

@ -12,6 +12,7 @@ import { makeClass, relativeTimeFormat } from '/src/utils'
import EventAvailabilities from './EventAvailabilities' import EventAvailabilities from './EventAvailabilities'
import styles from './page.module.scss' import styles from './page.module.scss'
import appLink from '/src/utils/appLink'
interface PageProps { interface PageProps {
params: { id: string } params: { id: string }
@ -49,10 +50,10 @@ const Page = async ({ params }: PageProps) => {
>{t('common:created', { date: relativeTimeFormat(Temporal.Instant.fromEpochSeconds(event.created_at), i18n.language) })}</span> >{t('common:created', { date: relativeTimeFormat(Temporal.Instant.fromEpochSeconds(event.created_at), i18n.language) })}</span>
<Copyable className={styles.info}> <Copyable className={styles.info}>
{`https://crab.fit/${event.id}`} {appLink(event.id)}
</Copyable> </Copyable>
<p className={makeClass(styles.info, styles.noPrint)}> <p className={makeClass(styles.info, styles.noPrint)}>
<Trans i18nKey="event:nav.shareinfo" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${event.id}`)}`}>_</a>_</Trans> <Trans i18nKey="event:nav.shareinfo" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} ${appLink(event.id)}`)}`}>_</a>_</Trans>
</p> </p>
</Content> </Content>
</Suspense> </Suspense>

View file

@ -1,6 +1,5 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import { Karla } from 'next/font/google' import { Karla } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react'
import Egg from '/src/components/Egg/Egg' import Egg from '/src/components/Egg/Egg'
import Settings from '/src/components/Settings/Settings' import Settings from '/src/components/Settings/Settings'
@ -9,11 +8,12 @@ import { fallbackLng } from '/src/i18n/options'
import { useTranslation } from '/src/i18n/server' import { useTranslation } from '/src/i18n/server'
import './global.css' import './global.css'
import appLink from '../utils/appLink'
const karla = Karla({ subsets: ['latin'] }) const karla = Karla({ subsets: ['latin'] })
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL('https://crab.fit'), metadataBase: new URL(appLink('')),
title: { title: {
absolute: 'Crab Fit', absolute: 'Crab Fit',
template: '%s - Crab Fit', template: '%s - Crab Fit',
@ -44,7 +44,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
{children} {children}
<Analytics />
</body> </body>
</html> </html>
} }

View file

@ -40,10 +40,10 @@ const Page = async () => {
<Video /> <Video />
<DownloadButtons /> {/* <DownloadButtons /> */}
<P><Trans i18nKey="about.content.p3" t={t} i18n={i18n}>_<a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">_</a>_</Trans></P> <P><Trans i18nKey="about.content.p3" t={t} i18n={i18n}>_<a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">_</a><a href="https://techwork.zone" target="_blank" rel="noreferrer noopener">_</a>_</Trans></P>
<P><Trans i18nKey="about.content.p4" t={t} i18n={i18n}>_<a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer noopener">_</a>_<Link href="/privacy" rel="license">_</Link>_</Trans></P> <P><Trans i18nKey="about.content.p4" t={t} i18n={i18n}>_<a href="https://git.techwork.zone/scott/crabfit" target="_blank" rel="noreferrer noopener">_</a>_<Link href="/privacy" rel="license">_</Link>_</Trans></P>
<P>{t('about.content.p6')}</P> <P>{t('about.content.p6')}</P>
<P>{t('about.content.p5')}</P> <P>{t('about.content.p5')}</P>
</Content> </Content>

View file

@ -5,6 +5,7 @@ import { EventResponse } from '/src/config/api'
import { useTranslation } from '/src/i18n/client' import { useTranslation } from '/src/i18n/client'
import styles from './EventInfo.module.scss' import styles from './EventInfo.module.scss'
import appLink from '/src/utils/appLink'
interface EventInfoProps { interface EventInfoProps {
event: EventResponse event: EventResponse
@ -16,10 +17,10 @@ const EventInfo = ({ event }: EventInfoProps) => {
return <div className={styles.wrapper}> return <div className={styles.wrapper}>
<h2>{event.name}</h2> <h2>{event.name}</h2>
<Copyable className={styles.info}> <Copyable className={styles.info}>
{`https://crab.fit/${event.id}`} {appLink(event.id)}
</Copyable> </Copyable>
<p className={styles.info}> <p className={styles.info}>
<Trans i18nKey="event:nav.shareinfo_alt" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t('nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('nav.email_body')} https://crab.fit/${event.id}`)}`} target="_blank">_</a>_</Trans> <Trans i18nKey="event:nav.shareinfo_alt" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t('nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('nav.email_body')} ${appLink(event.id)}`)}`} target="_blank">_</a>_</Trans>
</p> </p>
</div> </div>
} }

View file

@ -6,6 +6,7 @@ import logo from '/src/res/logo.svg'
import { makeClass } from '/src/utils' import { makeClass } from '/src/utils'
import styles from './Header.module.scss' import styles from './Header.module.scss'
import appLink from '/src/utils/appLink'
const samuraiBob = localFont({ const samuraiBob = localFont({
src: './samuraibob.woff2', src: './samuraibob.woff2',
@ -29,11 +30,11 @@ const Header = async ({ isFull, isSmall }: HeaderProps) => {
{isFull ? <> {isFull ? <>
{!isSmall && <img className={styles.bigLogo} src={logo.src} height={512} width={512} alt="" />} {!isSmall && <img className={styles.bigLogo} src={logo.src} height={512} width={512} alt="" />}
<span className={makeClass(styles.subtitle, samuraiBob.className, !/^[A-Za-z ]+$/.test(t('home:create')) && styles.hasAltChars)}>{t('home:create')}</span> <span className={makeClass(styles.subtitle, samuraiBob.className, !/^[A-Za-z ]+$/.test(t('home:create')) && styles.hasAltChars)}>{t('home:create')}</span>
<h1 className={makeClass(styles.bigTitle, molot.className)}>CRAB FIT</h1> <h1 className={makeClass(styles.bigTitle, molot.className)}>MEETING</h1>
</> : <Link href="/" className={styles.link}> </> : <Link href="/" className={styles.link}>
<div className={styles.top}> <div className={styles.top}>
<img className={styles.logo} src={logo.src} height={512} width={512} alt="" /> <img className={styles.logo} src={logo.src} height={512} width={512} alt="" />
<span className={makeClass(styles.title, molot.className)}>CRAB FIT</span> <span className={makeClass(styles.title, molot.className)}>MEETING</span>
</div> </div>
<span className={styles.tagline}>{t('common:tagline')}</span> <span className={styles.tagline}>{t('common:tagline')}</span>
</Link>} </Link>}

View file

@ -4,7 +4,7 @@
"cta": "Create your own Crab Fit!", "cta": "Create your own Crab Fit!",
"created": "Created {{date}}", "created": "Created {{date}}",
"donate": { "donate": {
"info": "Thank you for using Crab Fit. If you like it, consider donating.", "info": "Thank you for using Crab Fit. If you like it, consider donating to the upstream author.",
"button": "Donate", "button": "Donate",
"title": "Every amount counts :)" "title": "Every amount counts :)"
}, },

View file

@ -45,8 +45,8 @@
"events": "Events created", "events": "Events created",
"availabilities": "Availabilities entered", "availabilities": "Availabilities entered",
"content": { "content": {
"p1": "Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<1/><2>Learn more about how to Crab Fit</2>.", "p1": "This service is a soft fork of Crab Fit. Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<1/><2>Learn more about how to Crab Fit</2>.",
"p3": "Created by <1>Ben Grant</1>, Crab Fit is the modern-day solution to your group event planning debates.", "p3": "Created by <1>Ben Grant</1> and hosted by the <2>Tech Workers' Syndicate</2>, Crab Fit is the modern-day solution to your group event planning debates.",
"p4": "The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <1>repository</1>. By using Crab Fit you agree to the <3>privacy policy</3>.", "p4": "The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <1>repository</1>. By using Crab Fit you agree to the <3>privacy policy</3>.",
"p6": "To protect your privacy, events are deleted after 3 months of inactivity, and all passwords are securely hashed.", "p6": "To protect your privacy, events are deleted after 3 months of inactivity, and all passwords are securely hashed.",
"p5": "Consider donating below if it helped you out so Crab Fit can stay free for everyone. 🦀" "p5": "Consider donating below if it helped you out so Crab Fit can stay free for everyone. 🦀"

View file

@ -0,0 +1,7 @@
export default function appLink(path: string) {
const proto = process?.env?.NODE_ENV === 'production' ? 'https' : 'http'
const host =
process?.env?.NEXT_PUBLIC_HOSTNAME ??
(process?.env?.NODE_ENV === 'production' ? 'crab.fit' : 'localhost:3000')
return `${proto}://${host}/${path}`
}