Merge pull request #675 from Freika/feature/self-hosted-mode

Self-hosted mode
This commit is contained in:
Evgenii Burmakin 2025-02-15 18:43:42 +01:00 committed by GitHub
commit 2c39ebcaa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 617 additions and 356 deletions

View file

@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.24.2 - 2025-02-15
## Fixed
- Fixed a bug where background jobs to import Immich and Photoprism geolocation data data could not be created by non-admin users.
- Fixed a bug where upon point deletion there was an error it was not being removed from the map, while it was actually deleted from the database. #883
### Changed
- Restrict access to Sidekiq in non self-hosted mode.
- Restrict access to background jobs in non self-hosted mode.
- Restrict access to users management in non self-hosted mode.
# 0.24.1 - 2025-02-13
## Custom map tiles

View file

@ -3,7 +3,7 @@
class ApplicationController < ActionController::Base
include Pundit::Authorization
before_action :unread_notifications
before_action :unread_notifications, :set_self_hosted_status
protected
@ -18,4 +18,16 @@ class ApplicationController < ActionController::Base
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
end
def authenticate_self_hosted!
return if DawarichSettings.self_hosted?
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
end
private
def set_self_hosted_status
@self_hosted = DawarichSettings.self_hosted?
end
end

View file

@ -1,8 +1,10 @@
# frozen_string_literal: true
class Settings::BackgroundJobsController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_admin!
before_action :authenticate_self_hosted!
before_action :authenticate_admin!, unless: lambda {
%w[start_immich_import start_photoprism_import].include?(params[:job_name])
}
def index
@queues = Sidekiq::Queue.all
@ -13,7 +15,15 @@ class Settings::BackgroundJobsController < ApplicationController
flash.now[:notice] = 'Job was successfully created.'
redirect_to settings_background_jobs_path, notice: 'Job was successfully created.'
redirect_path =
case params[:job_name]
when 'start_immich_import', 'start_photoprism_import'
imports_path
else
settings_background_jobs_path
end
redirect_to redirect_path, notice: 'Job was successfully created.'
end
def destroy

View file

@ -1,8 +1,8 @@
# frozen_string_literal: true
class Settings::UsersController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_admin!
before_action :authenticate_self_hosted!
def index
@users = User.order(created_at: :desc)

View file

@ -0,0 +1,23 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
selfHosted: Boolean
}
// Every controller that extends BaseController and uses initialize()
// should call super.initialize()
// Example:
// export default class extends BaseController {
// initialize() {
// super.initialize()
// }
// }
initialize() {
// Get the self-hosted value from the HTML root element
if (!this.hasSelfHostedValue) {
const selfHosted = document.documentElement.dataset.selfHosted === 'true'
this.selfHostedValue = selfHosted
}
}
}

View file

@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
// Connects to data-controller="checkbox-select-all"
export default class extends Controller {
export default class extends BaseController {
static targets = ["parent", "child"]
connect() {

View file

@ -2,9 +2,9 @@
// - trips/new
// - trips/edit
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
export default class extends Controller {
export default class extends BaseController {
static targets = ["startedAt", "endedAt", "apiKey"]
static values = { tripsId: String }

View file

@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus";
import BaseController from "./base_controller";
import consumer from "../channels/consumer";
export default class extends Controller {
export default class extends BaseController {
static targets = ["index"];
connect() {

View file

@ -1,8 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
import L from "leaflet"
import { showFlashMessage } from "../maps/helpers"
export default class extends Controller {
export default class extends BaseController {
static targets = ["urlInput", "mapContainer", "saveButton"]
DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'

View file

@ -13,26 +13,16 @@ import {
import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas";
import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers";
import {
osmMapLayer,
osmHotMapLayer,
OPNVMapLayer,
openTopoMapLayer,
cyclOsmMapLayer,
esriWorldStreetMapLayer,
esriWorldTopoMapLayer,
esriWorldImageryMapLayer,
esriWorldGrayCanvasMapLayer
} from "../maps/layers";
import { showFlashMessage, fetchAndDisplayPhotos } from "../maps/helpers";
import { countryCodesMap } from "../maps/country_codes";
import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
import { TileMonitor } from "../maps/tile_monitor";
import BaseController from "./base_controller";
import { createAllMapLayers } from "../maps/layers";
export default class extends Controller {
export default class extends BaseController {
static targets = ["container"];
settingsButtonAdded = false;
@ -41,6 +31,7 @@ export default class extends Controller {
trackedMonthsCache = null;
connect() {
super.connect();
console.log("Map controller connected");
this.apiKey = this.element.dataset.api_key;
@ -402,17 +393,7 @@ export default class extends Controller {
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
let maps = {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
};
let maps = createAllMapLayers(this.map, selectedLayerName);
// Add custom map if it exists in settings
if (this.userSettings.maps && this.userSettings.maps.url) {
@ -536,13 +517,13 @@ export default class extends Controller {
if (this.layerControl) {
this.map.removeControl(this.layerControl);
const controlsLayer = {
Points: this.markersLayer,
Routes: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer,
Areas: this.areasLayer,
Photos: this.photoMarkers
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.layerGroup(),
"Fog of War": new this.fogOverlay(),
"Scratch map": this.scratchLayer || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup()
};
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
}

View file

@ -1,11 +1,12 @@
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
import consumer from "../channels/consumer"
export default class extends Controller {
export default class extends BaseController {
static targets = ["badge", "list"]
static values = { userId: Number }
initialize() {
super.initialize()
this.subscription = null
}

View file

@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
export default class extends Controller {
export default class extends BaseController {
static values = {
timeout: Number
}

View file

@ -1,10 +1,10 @@
// This controller is being used on:
// - trips/index
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
import L from "leaflet"
export default class extends Controller {
export default class extends BaseController {
static values = {
tripId: Number,
path: String,

View file

@ -3,7 +3,7 @@
// - trips/edit
// - trips/new
import { Controller } from "@hotwired/stimulus"
import BaseController from "./base_controller"
import L from "leaflet"
import {
osmMapLayer,
@ -22,7 +22,7 @@ import {
showFlashMessage
} from '../maps/helpers';
export default class extends Controller {
export default class extends BaseController {
static targets = ["container", "startedAt", "endedAt"]
static values = { }

View file

@ -1,12 +1,12 @@
import { Controller } from "@hotwired/stimulus"
import L, { latLng } from "leaflet";
import { osmMapLayer } from "../maps/layers";
import BaseController from "./base_controller"
import L from "leaflet"
import { osmMapLayer } from "../maps/layers"
// This controller is used to display a map of all coordinates for a visit
// on the "Map" modal of a visit on the Visits page
export default class extends Controller {
static targets = ["container"];
export default class extends BaseController {
static targets = ["container"]
connect() {
this.coordinates = JSON.parse(this.element.dataset.coordinates);

View file

@ -1,10 +1,13 @@
import { Controller } from "@hotwired/stimulus";
import BaseController from "./base_controller"
export default class extends Controller {
export default class extends BaseController {
static targets = ["name", "input"]
connect() {
this.visitId = this.element.dataset.id;
this.apiKey = this.element.dataset.api_key;
this.visitId = this.element.dataset.id;
this.element.addEventListener("visit-name:updated", this.updateAll.bind(this));
}
// Action to handle selection change

View file

@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus";
import BaseController from "./base_controller"
// This controller is used to handle the updating of visit names on the Visits page
export default class extends Controller {
export default class extends BaseController {
static targets = ["name", "input"];
connect() {

View file

@ -87,10 +87,19 @@ export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {
}
export function showFlashMessage(type, message) {
// Create the outer flash container div
// Get or create the flash container
let flashContainer = document.getElementById('flash-messages');
if (!flashContainer) {
flashContainer = document.createElement('div');
flashContainer.id = 'flash-messages';
flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-40';
document.body.appendChild(flashContainer);
}
// Create the flash message div
const flashDiv = document.createElement('div');
flashDiv.setAttribute('data-controller', 'removals');
flashDiv.className = `flex items-center fixed top-5 right-5 ${classesForFlash(type)} py-3 px-5 rounded-lg`;
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-40`;
// Create the message div
const messageDiv = document.createElement('div');
@ -101,6 +110,7 @@ export function showFlashMessage(type, message) {
const closeButton = document.createElement('button');
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('data-action', 'click->removals#remove');
closeButton.className = 'ml-auto'; // Ensures button stays on the right
// Create the SVG icon for the close button
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
@ -116,21 +126,22 @@ export function showFlashMessage(type, message) {
closeIconPath.setAttribute('stroke-width', '2');
closeIconPath.setAttribute('d', 'M6 18L18 6M6 6l12 12');
// Append the path to the SVG
// Append all elements
closeIcon.appendChild(closeIconPath);
// Append the SVG to the close button
closeButton.appendChild(closeIcon);
// Append the message and close button to the flash div
flashDiv.appendChild(messageDiv);
flashDiv.appendChild(closeButton);
flashContainer.appendChild(flashDiv);
// Append the flash message to the body or a specific flash container
document.body.appendChild(flashDiv);
// Optional: Automatically remove the flash message after 5 seconds
// Automatically remove after 5 seconds
setTimeout(() => {
flashDiv.remove();
if (flashDiv && flashDiv.parentNode) {
flashDiv.remove();
// Remove container if empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}
}, 5000);
}

View file

@ -1,4 +1,38 @@
// Yeah I know it should be DRY but this is me doing a KISS at 21:00 on a Sunday night
// Import the maps configuration
// In non-self-hosted mode, we need to mount external maps_config.js to the container
import { mapsConfig } from './maps_config';
export function createMapLayer(map, selectedLayerName, layerKey) {
const config = mapsConfig[layerKey];
if (!config) {
console.warn(`No configuration found for layer: ${layerKey}`);
return null;
}
let layer = L.tileLayer(config.url, {
maxZoom: config.maxZoom,
attribution: config.attribution,
// Add any other config properties that might be needed
});
if (selectedLayerName === layerKey) {
return layer.addTo(map);
} else {
return layer;
}
}
// Helper function to create all map layers
export function createAllMapLayers(map, selectedLayerName) {
const layers = {};
Object.keys(mapsConfig).forEach(layerKey => {
layers[layerKey] = createMapLayer(map, selectedLayerName, layerKey);
});
return layers;
}
export function osmMapLayer(map, selectedLayerName) {
let layerName = 'OpenStreetMap';
@ -57,166 +91,6 @@ export function openTopoMapLayer(map, selectedLayerName) {
}
}
// export function stadiaAlidadeSmoothMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaAlidadeSmooth';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaAlidadeSmoothDarkMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaAlidadeSmoothDark';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaAlidadeSatelliteMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaAlidadeSatellite';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_satellite/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; CNES, Distribution Airbus DS, © Airbus DS, © PlanetObserver (Contains Copernicus Data) | &copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'jpg'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaOsmBrightMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaOsmBright';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaOutdoorMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaOutdoor';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaStamenTonerMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaStamenToner';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.stamen.com/" target="_blank">Stamen Design</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaStamenTonerBackgroundMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaStamenTonerBackground';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_toner_background/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.stamen.com/" target="_blank">Stamen Design</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaStamenTonerLiteMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaStamenTonerLite';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 20,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.stamen.com/" target="_blank">Stamen Design</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaStamenWatercolorMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaStamenWatercolor';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.{ext}', {
// minZoom: 1,
// maxZoom: 16,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.stamen.com/" target="_blank">Stamen Design</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'jpg'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
// export function stadiaStamenTerrainMapLayer(map, selectedLayerName) {
// let layerName = 'stadiaStamenTerrain';
// let layer = L.tileLayer('https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.{ext}', {
// minZoom: 0,
// maxZoom: 18,
// attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.stamen.com/" target="_blank">Stamen Design</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
// ext: 'png'
// });
// if (selectedLayerName === layerName) {
// return layer.addTo(map);
// } else {
// return layer;
// }
// }
export function cyclOsmMapLayer(map, selectedLayerName) {
let layerName = 'cyclOsm';
let layer = L.tileLayer('https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', {

View file

@ -0,0 +1,44 @@
export const mapsConfig = {
"OpenStreetMap": {
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
maxZoom: 19,
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
},
"OpenStreetMap.HOT": {
url: "https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
maxZoom: 19,
attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France"
},
"OPNV": {
url: "https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png",
maxZoom: 18,
attribution: "Map <a href='https://memomaps.de/'>memomaps.de</a> <a href='http://creativecommons.org/licenses/by-sa/2.0/'>CC-BY-SA</a>, map data &copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
},
"openTopo": {
url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
maxZoom: 17,
attribution: "Map data: &copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors, <a href='http://viewfinderpanoramas.org'>SRTM</a> | Map style: &copy; <a href='https://opentopomap.org'>OpenTopoMap</a> (<a href='https://creativecommons.org/licenses/by-sa/3.0/'>CC-BY-SA</a>)"
},
"cyclOsm": {
url: "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
maxZoom: 20,
attribution: "<a href='https://github.com/cyclosm/cyclosm-cartocss-style/releases' title='CyclOSM - Open Bicycle render'>CyclOSM</a> | Map data: &copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
},
"esriWorldStreet": {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}",
attribution: "Tiles &copy; Esri &mdash; Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012"
},
"esriWorldTopo": {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
attribution: "Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community"
},
"esriWorldImagery": {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: "Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"
},
"esriWorldGrayCanvas": {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}",
attribution: "Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ",
maxZoom: 16
}
};

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html data-theme="<%= app_theme %>">
<html data-theme="<%= app_theme %>" data-self-hosted="<%= @self_hosted %>">
<head>
<title><%= full_title(yield(:title)) %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">

View file

@ -1,6 +1,6 @@
<div role="tablist" class="tabs tabs-lifted tabs-lg">
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %>
<% if current_user.admin? %>
<% if DawarichSettings.self_hosted? && current_user.admin? %>
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>
<% end %>

View file

@ -1,4 +1,4 @@
<% content_for :title, "Background jobs" %>
<% content_for :title, "Map settings" %>
<div class="min-h-content w-full my-5">
<%= render 'settings/navigation' %>

View file

@ -27,7 +27,7 @@
</div>
</div>
<div role="alert" class="alert mt-5">
<div role="alert" class="alert my-5">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
SELF_HOSTED = ENV.fetch('SELF_HOSTED', 'true') == 'true'
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
@ -11,7 +13,7 @@ TELEMETRY_URL = 'https://influxdb2.frey.today/api/v2/write'
# Reverse geocoding settings
PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil)
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true'
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'false') == 'true'
NOMINATIM_API_HOST = ENV.fetch('NOMINATIM_API_HOST', nil)
NOMINATIM_API_KEY = ENV.fetch('NOMINATIM_API_KEY', nil)

View file

@ -18,6 +18,10 @@ class DawarichSettings
@geoapify_enabled ||= GEOAPIFY_API_KEY.present?
end
def self_hosted?
@self_hosted ||= SELF_HOSTED
end
def prometheus_exporter_enabled?
@prometheus_exporter_enabled ||=
ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' &&

View file

@ -6,7 +6,7 @@ Rails.application.routes.draw do
mount ActionCable.server => '/cable'
mount Rswag::Api::Engine => '/api-docs'
mount Rswag::Ui::Engine => '/api-docs'
authenticate :user, ->(u) { u.admin? } do
authenticate :user, ->(u) { u.admin? && DawarichSettings.self_hosted? } do
mount Sidekiq::Web => '/sidekiq'
end
@ -53,10 +53,15 @@ Rails.application.routes.draw do
constraints: { year: /\d{4}/, month: /\d{1,2}|all/ }
root to: 'home#index'
devise_for :users, skip: [:registrations]
as :user do
get 'users/edit' => 'devise/registrations#edit', :as => 'edit_user_registration'
put 'users' => 'devise/registrations#update', :as => 'user_registration'
if SELF_HOSTED
devise_for :users, skip: [:registrations]
as :user do
get 'users/edit' => 'devise/registrations#edit', :as => 'edit_user_registration'
put 'users' => 'devise/registrations#update', :as => 'user_registration'
end
else
devise_for :users
end
get 'map', to: 'map#index'

View file

@ -8,19 +8,13 @@ RSpec.describe '/settings/background_jobs', type: :request do
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
context 'when user is not authenticated' do
it 'redirects to sign in page' do
get settings_background_jobs_url
expect(response).to redirect_to(new_user_session_url)
context 'when Dawarich is in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
end
context 'when user is authenticated' do
before { sign_in create(:user) }
context 'when user is not an admin' do
it 'redirects to root page' do
context 'when user is not authenticated' do
it 'redirects to sign in page' do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
@ -28,49 +22,189 @@ RSpec.describe '/settings/background_jobs', type: :request do
end
end
context 'when user is an admin' do
before { sign_in create(:user, :admin) }
context 'when user is authenticated' do
let(:user) { create(:user, admin: false) }
describe 'GET /index' do
it 'renders a successful response' do
before { sign_in user }
context 'when user is not an admin' do
it 'redirects to root page' do
get settings_background_jobs_url
expect(response).to be_successful
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
context 'when job name is start_immich_import' do
it 'redirects to imports page' do
post settings_background_jobs_url, params: { job_name: 'start_immich_import' }
expect(response).to redirect_to(imports_url)
end
it 'enqueues a new job' do
expect do
post settings_background_jobs_url, params: { job_name: 'start_immich_import' }
end.to have_enqueued_job(EnqueueBackgroundJob)
end
end
context 'when job name is start_photoprism_import' do
it 'redirects to imports page' do
get settings_background_jobs_url, params: { job_name: 'start_photoprism_import' }
end
it 'enqueues a new job' do
expect do
post settings_background_jobs_url, params: { job_name: 'start_photoprism_import' }
end.to have_enqueued_job(EnqueueBackgroundJob)
end
end
end
describe 'POST /create' do
let(:params) { { job_name: 'start_reverse_geocoding' } }
context 'when user is an admin' do
before { sign_in create(:user, :admin) }
context 'with valid parameters' do
it 'enqueues a new job' do
expect do
post settings_background_jobs_url, params:
end.to have_enqueued_job(EnqueueBackgroundJob)
describe 'GET /index' do
it 'renders a successful response' do
get settings_background_jobs_url
expect(response).to be_successful
end
end
describe 'POST /create' do
let(:params) { { job_name: 'start_reverse_geocoding' } }
context 'with valid parameters' do
it 'enqueues a new job' do
expect do
post settings_background_jobs_url, params:
end.to have_enqueued_job(EnqueueBackgroundJob)
end
it 'redirects to the created settings_background_job' do
post(settings_background_jobs_url, params:)
expect(response).to redirect_to(settings_background_jobs_url)
end
end
end
describe 'DELETE /destroy' do
it 'clears the Sidekiq queue' do
queue = instance_double(Sidekiq::Queue)
allow(Sidekiq::Queue).to receive(:new).and_return(queue)
expect(queue).to receive(:clear)
delete settings_background_job_url('queue_name')
end
it 'redirects to the created settings_background_job' do
post(settings_background_jobs_url, params:)
it 'redirects to the settings_background_jobs list' do
delete settings_background_job_url('queue_name')
expect(response).to redirect_to(settings_background_jobs_url)
end
end
end
end
end
describe 'DELETE /destroy' do
it 'clears the Sidekiq queue' do
queue = instance_double(Sidekiq::Queue)
allow(Sidekiq::Queue).to receive(:new).and_return(queue)
context 'when Dawarich is not in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
end
expect(queue).to receive(:clear)
context 'when user is not authenticated' do
it 'redirects to sign in page' do
get settings_background_jobs_url
delete settings_background_job_url('queue_name')
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
context 'when user is authenticated' do
let(:user) { create(:user) }
before { sign_in user }
describe 'GET /index' do
it 'redirects to root page' do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
it 'redirects to the settings_background_jobs list' do
context 'when user is an admin' do
before { sign_in create(:user, :admin) }
it 'redirects to root page' do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
end
describe 'POST /create' do
it 'redirects to root page' do
post settings_background_jobs_url, params: { job_name: 'start_reverse_geocoding' }
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
context 'when job name is start_immich_import' do
it 'redirects to imports page' do
post settings_background_jobs_url, params: { job_name: 'start_immich_import' }
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
context 'when job name is start_photoprism_import' do
it 'redirects to imports page' do
post settings_background_jobs_url, params: { job_name: 'start_photoprism_import' }
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
context 'when user is an admin' do
before { sign_in create(:user, :admin) }
it 'redirects to root page' do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
end
describe 'DELETE /destroy' do
it 'redirects to root page' do
delete settings_background_job_url('queue_name')
expect(response).to redirect_to(settings_background_jobs_url)
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
context 'when user is an admin' do
before { sign_in create(:user, :admin) }
it 'redirects to root page' do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
end

View file

@ -6,81 +6,123 @@ RSpec.describe '/settings/users', type: :request do
let(:valid_attributes) { { email: 'user@domain.com', password: '4815162342' } }
let!(:admin) { create(:user, :admin) }
context 'when user is not authenticated' do
it 'redirects to sign in page' do
post settings_users_url, params: { user: valid_attributes }
expect(response).to redirect_to(new_user_session_url)
context 'when Dawarich is in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
end
context 'when user is authenticated' do
context 'when user is not an admin' do
before { sign_in create(:user) }
it 'redirects to root page' do
context 'when user is not authenticated' do
it 'redirects to sign in page' do
post settings_users_url, params: { user: valid_attributes }
expect(response).to redirect_to(root_url)
end
end
context 'when user is an admin' do
describe 'POST /create' do
before { sign_in admin }
context 'when user is authenticated' do
context 'when user is not an admin' do
before { sign_in create(:user) }
context 'with valid parameters' do
it 'creates a new User' do
expect do
post settings_users_url, params: { user: valid_attributes }
end.to change(User, :count).by(1)
it 'redirects to root page' do
post settings_users_url, params: { user: valid_attributes }
expect(User.last.email).to eq(valid_attributes[:email])
expect(User.last.valid_password?(valid_attributes[:password])).to be_truthy
end
it 'redirects to the created settings_user' do
post settings_users_url, params: { user: valid_attributes }
expect(response).to redirect_to(settings_users_url)
expect(flash[:notice]).to eq('User was successfully created')
end
end
context 'with invalid parameters' do
let(:invalid_attributes) { { email: nil } }
it 'does not create a new User' do
expect do
post settings_users_url, params: { user: invalid_attributes }
end.to change(User, :count).by(0)
end
it 'renders a response with 422 status (i.e. to display the "new" template)' do
post settings_users_url, params: { user: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
expect(response).to redirect_to(root_url)
end
end
describe 'PATCH /update' do
let(:user) { create(:user) }
context 'when user is an admin' do
describe 'POST /create' do
before { sign_in admin }
before { sign_in admin }
context 'with valid parameters' do
it 'creates a new User' do
expect do
post settings_users_url, params: { user: valid_attributes }
end.to change(User, :count).by(1)
context 'with valid parameters' do
let(:new_attributes) { { email: FFaker::Internet.email, password: '4815162342' } }
expect(User.last.email).to eq(valid_attributes[:email])
expect(User.last.valid_password?(valid_attributes[:password])).to be_truthy
end
it 'updates the requested user' do
patch settings_user_url(user), params: { user: new_attributes }
it 'redirects to the created settings_user' do
post settings_users_url, params: { user: valid_attributes }
user.reload
expect(user.email).to eq(new_attributes[:email])
expect(user.valid_password?(new_attributes[:password])).to be_truthy
expect(response).to redirect_to(settings_users_url)
expect(flash[:notice]).to eq('User was successfully created')
end
end
context 'with invalid parameters' do
let(:invalid_attributes) { { email: nil } }
it 'does not create a new User' do
expect do
post settings_users_url, params: { user: invalid_attributes }
end.to change(User, :count).by(0)
end
it 'renders a response with 422 status (i.e. to display the "new" template)' do
post settings_users_url, params: { user: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /update' do
let(:user) { create(:user) }
before { sign_in admin }
context 'with valid parameters' do
let(:new_attributes) { { email: FFaker::Internet.email, password: '4815162342' } }
it 'updates the requested user' do
patch settings_user_url(user), params: { user: new_attributes }
user.reload
expect(user.email).to eq(new_attributes[:email])
expect(user.valid_password?(new_attributes[:password])).to be_truthy
end
end
end
end
end
end
context 'when Dawarich is not in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
sign_in admin
end
describe 'GET /index' do
it 'redirects to root page' do
get settings_users_url
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
describe 'POST /create' do
it 'redirects to root page' do
post settings_users_url, params: { user: valid_attributes }
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
describe 'PATCH /update' do
let(:user) { create(:user) }
it 'redirects to root page' do
patch settings_user_url(user), params: { user: valid_attributes }
expect(response).to redirect_to(root_url)
expect(flash[:notice]).to eq('You are not authorized to perform this action.')
end
end
end
end

View file

@ -58,7 +58,7 @@ RSpec.describe 'Settings', type: :request do
end
it 'generates an API key for the user' do
expect { post '/settings/generate_api_key' }.to change { user.reload.api_key }
expect { post '/settings/generate_api_key' }.to(change { user.reload.api_key })
end
it 'redirects back' do
@ -83,4 +83,39 @@ RSpec.describe 'Settings', type: :request do
expect(user.reload.settings).to eq(params[:settings])
end
end
describe 'GET /settings/users' do
let!(:user) { create(:user, admin: true) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in user
end
context 'when self-hosted' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
it 'returns http success' do
get '/settings/users'
expect(response).to have_http_status(:success)
end
end
context 'when not self-hosted' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
end
it 'redirects to root path' do
get '/settings/users'
expect(response).to redirect_to(root_path)
end
end
end
end

View file

@ -3,39 +3,71 @@
require 'rails_helper'
RSpec.describe '/sidekiq', type: :request do
context 'when user is not authenticated' do
it 'redirects to sign in page' do
get sidekiq_url
context 'when Dawarich is in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
expect(response).to redirect_to('/users/sign_in')
context 'when user is not authenticated' do
it 'redirects to sign in page' do
get sidekiq_url
expect(response).to redirect_to('/users/sign_in')
end
end
context 'when user is authenticated' do
context 'when user is not admin' do
before { sign_in create(:user) }
it 'redirects to root page' do
get sidekiq_url
expect(response).to redirect_to(root_url)
end
it 'shows flash message' do
get sidekiq_url
expect(flash[:error]).to eq('You are not authorized to perform this action.')
end
end
context 'when user is admin' do
before { sign_in create(:user, :admin) }
it 'renders a successful response' do
get sidekiq_url
expect(response).to be_successful
end
end
end
end
context 'when user is authenticated' do
context 'when user is not admin' do
before { sign_in create(:user) }
context 'when Dawarich is not in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
Rails.application.reload_routes!
end
context 'when user is not authenticated' do
it 'redirects to sign in page' do
get sidekiq_url
expect(response).to redirect_to('/users/sign_in')
end
end
context 'when user is authenticated' do
before { sign_in create(:user, :admin) }
it 'redirects to root page' do
get sidekiq_url
expect(response).to redirect_to(root_url)
end
it 'shows flash message' do
get sidekiq_url
expect(flash[:error]).to eq('You are not authorized to perform this action.')
end
end
context 'when user is admin' do
before { sign_in create(:user, :admin) }
it 'renders a successful response' do
get sidekiq_url
expect(response).to be_successful
end
end
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Users', type: :request do
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'GET /users/sign_up' do
context 'when self-hosted' do
before do
stub_const('SELF_HOSTED', true)
end
it 'returns http success' do
get '/users/sign_up'
expect(response).to have_http_status(:not_found)
end
end
context 'when not self-hosted' do
before do
stub_const('SELF_HOSTED', false)
Rails.application.reload_routes!
end
it 'returns http success' do
get '/users/sign_up'
expect(response).to have_http_status(:success)
end
end
end
end