diff --git a/.gitignore b/.gitignore index 1a24d76..4997b34 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ yarn-error.log* .idea .vscode/ + +result diff --git a/backend/default.nix b/backend/default.nix new file mode 100644 index 0000000..76a86f1 --- /dev/null +++ b/backend/default.nix @@ -0,0 +1,22 @@ +{ lib +, buildGoModule +}: + +buildGoModule rec { + pname = "exam-poll-backend"; + version = "0.0.0"; + + src = ./.; + + vendorHash = "sha256-1QmLYg+1GQwqexz1eFzsWt+H9t1CCN7fMialc07UWrg="; + + postInstall = '' + mv $out/bin/exam-poll{,-backend} + ''; + + meta = { + license = lib.licenses.gpl3Plus; + maintainers = with lib.maintainers; [ fugi ]; + mainProgram = pname; + }; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d1cf501 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1704538339, + "narHash": "sha256-1734d3mQuux9ySvwf6axRWZRBhtcZA9Q8eftD6EZg6U=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "46ae0210ce163b3cba6c7da08840c1d63de9c701", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6c237e4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + (flake-utils.lib.eachDefaultSystem (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { + packages = { + exam-poll-frontend = pkgs.callPackage ./frontend { }; + exam-poll-backend = pkgs.callPackage ./backend { }; + }; + + formatter = pkgs.nixpkgs-fmt; + }) + ) // { + overlays.default = (_: prev: { + inherit (self.packages.${prev.system}) + exam-poll-frontend exam-poll-backend; + }); + + nixosModules.default = { + imports = [ ./module.nix ]; + + nixpkgs.overlays = [ self.overlays.default ]; + }; + }; +} diff --git a/frontend/default.nix b/frontend/default.nix new file mode 100644 index 0000000..d225ac6 --- /dev/null +++ b/frontend/default.nix @@ -0,0 +1,72 @@ +{ lib +, fetchYarnDeps +, mkYarnPackage +, nodejs +, makeWrapper +, localApiBaseUrl ? null +, publicApiBaseUrl ? null +}: + +mkYarnPackage rec { + pname = "exam-poll-frontend"; + version = "0.1.0"; + src = ./.; + + yarnLock = ./yarn.lock; + packageJSON = ./package.json; + + offlineCache = fetchYarnDeps { + inherit yarnLock; + hash = "sha256-qyo62/Apld/JslCshF6iSwegbfBl41zpYHabj5/0ARs="; + }; + + NODE_ENV = "production"; + API_BASEURL = localApiBaseUrl; + NEXT_PUBLIC_API_BASEURL = publicApiBaseUrl; + + nativeBuildInputs = [ makeWrapper ]; + + configurePhase = '' + runHook preConfigure + + ln -s $node_modules node_modules + + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + + export HOME=$(mktemp -d) + # pipe to cat to disable fancy progress indicators cluttering the log + yarn --offline run build | cat + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + export OUT_LIBEXEC="$out/libexec/${pname}" + mkdir -p $out $OUT_LIBEXEC + # copy compiled files + cp -r .next/standalone/. $OUT_LIBEXEC + # copy static files too + cp -r .next/static $OUT_LIBEXEC/.next/static + cp -r public $OUT_LIBEXEC/public + # server wrapper + makeWrapper '${nodejs}/bin/node' "$out/bin/${pname}" \ + --add-flags "$OUT_LIBEXEC/server.js" + + runHook postInstall + ''; + + dontFixup = true; + doDist = false; + + meta = { + license = lib.licenses.gpl3Plus; + maintainers = with lib.maintainers; [ fugi ]; + mainProgram = pname; + }; +} diff --git a/frontend/next.config.js b/frontend/next.config.js index 9c9138a..c98f570 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ module.exports = { + output: 'standalone', reactStrictMode: true, i18n: { // https://nextjs.org/docs/advanced-features/i18n-routing diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..a537f00 --- /dev/null +++ b/module.nix @@ -0,0 +1,158 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.services.exam-poll; + + mkService = service: lib.mkMerge [ + { + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "exec"; + Restart = "on-failure"; + DynamicUser = true; + UMask = "0077"; + WorkingDirectory = /tmp; + # Hardening + CapabilityBoundingSet = [ "" ]; + DeviceAllow = [ "" ]; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + }; + } + service + ]; +in +{ + options.services.exam-poll = with lib; { + enable = mkEnableOption "Exam Poll"; + + configureNginx = mkOption { + type = types.bool; + default = true; + description = "Whether to configure nginx as reverse proxy."; + }; + + frontend = { + package = mkOption { + type = types.package; + default = pkgs.exam-poll-frontend; + description = "The package to use."; + }; + + port = mkOption { + type = types.port; + default = 3000; + description = "The port to listen on."; + }; + + hostName = mkOption { + type = types.str; + example = "exam-poll.example.com"; + description = "The hostname the application will be served on."; + }; + }; + + backend = { + package = mkOption { + type = types.package; + default = pkgs.exam-poll-backend; + description = "The package to use."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = "The port to listen on."; + }; + + hostName = mkOption { + type = types.str; + example = "exam-poll-api.example.com"; + description = "The hostname the api will be served on."; + }; + }; + + mongodb = { + uri = mkOption { + type = types.str; + example = "mongodb://localhost:27017"; + description = "MongoDB connection string."; + }; + + database = mkOption { + type = types.str; + description = "The database name."; + }; + + collection = mkOption { + type = types.str; + description = "The collection name."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services = { + exam-poll-frontend = mkService { + description = "Exam Poll frontend"; + environment = { + PORT = toString cfg.frontend.port; + }; + serviceConfig = { + ExecStart = lib.getExe (cfg.frontend.package.override { + localApiBaseUrl = "http://localhost:${toString cfg.backend.port}"; + publicApiBaseUrl = "https://${cfg.backend.hostName}"; + }); + }; + }; + + exam-poll-backend = mkService { + description = "Exam Poll backend"; + environment = { + EXAM_POLL_HTTP_LISTEN = "localhost:${toString cfg.backend.port}"; + EXAM_POLL_CORS_LIST = "https://${cfg.frontend.hostName},localhost"; + EXAM_POLL_MONGODB = cfg.mongodb.uri; + EXAM_POLL_DATABASE = cfg.mongodb.database; + EXAM_POLL_COLLECTION = cfg.mongodb.collection; + }; + serviceConfig = { + ExecStart = lib.getExe pkgs.exam-poll-backend; + }; + }; + }; + + services.nginx = lib.mkIf cfg.configureNginx { + enable = true; + recommendedProxySettings = true; + virtualHosts = { + ${cfg.frontend.hostName} = { + forceSSL = lib.mkDefault true; + enableACME = lib.mkDefault true; + locations."/".proxyPass = "http://localhost:${toString cfg.frontend.port}"; + }; + ${cfg.backend.hostName} = { + forceSSL = lib.mkDefault true; + enableACME = lib.mkDefault true; + locations."/".proxyPass = "http://localhost:${toString cfg.backend.port}"; + }; + }; + }; + }; +}