MapComplete/src/Logic/Osm/OsmConnection.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

613 lines
22 KiB
TypeScript
Raw Normal View History

2023-09-27 22:21:35 +02:00
import { osmAuth } from "osm-auth"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import { OsmPreferences } from "./OsmPreferences"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { AuthConfig } from "./AuthConfig"
import Constants from "../../Models/Constants"
interface OsmUserInfo {
id: number
display_name: string
account_created: string
description: string
contributor_terms: { agreed: boolean }
roles: []
changesets: { count: number }
traces: { count: number }
blocks: { received: { count: number; active: number } }
}
export default class UserDetails {
2023-09-27 22:21:35 +02:00
public loggedIn = false
public name = "Not logged in"
public uid: number
public csCount = 0
public img?: string
public unreadMessages = 0
public totalMessages: number = 0
public home: { lon: number; lat: number }
public backend: string
public account_created: string
public tracesCount: number = 0
public description: string
public languages: string[]
2023-09-27 22:21:35 +02:00
constructor(backend: string) {
this.backend = backend
}
2023-09-01 21:36:39 +02:00
}
export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable"
2020-06-24 00:35:19 +02:00
export class OsmConnection {
public auth: osmAuth
2023-09-27 22:21:35 +02:00
public userDetails: UIEventSource<UserDetails>
public isLoggedIn: Store<boolean>
public gpxServiceIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>(
"unknown"
)
public apiIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>(
"unknown"
)
public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">(
"not-attempted"
)
public preferencesHandler: OsmPreferences
public readonly _oauth_config: AuthConfig
private readonly _dryRun: Store<boolean>
private readonly fakeUser: boolean
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []
private readonly _iframeMode: boolean
2023-09-27 22:21:35 +02:00
private readonly _singlePage: boolean
private isChecking = false
private readonly _doCheckRegularly
2023-09-27 22:21:35 +02:00
constructor(options?: {
dryRun?: Store<boolean>
fakeUser?: false | boolean
oauth_token?: UIEventSource<string>
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean
2024-04-13 02:40:21 +02:00
attemptLogin?: true | boolean
/**
* If true: automatically check if we're still online every 5 minutes + fetch messages
*/
checkOnlineRegularly?: true | boolean
2023-09-27 22:21:35 +02:00
}) {
options ??= {}
this.fakeUser = options?.fakeUser ?? false
this._singlePage = options?.singlePage ?? true
this._oauth_config = Constants.osmAuthConfig
this._doCheckRegularly = options?.checkOnlineRegularly ?? true
2023-09-27 22:21:35 +02:00
console.debug("Using backend", this._oauth_config.url)
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top
// Check if there are settings available in environment variables, and if so, use those
if (
import.meta.env.VITE_OSM_OAUTH_CLIENT_ID !== undefined &&
import.meta.env.VITE_OSM_OAUTH_SECRET !== undefined
) {
console.debug("Using environment variables for oauth config")
2023-10-03 20:09:19 +02:00
this._oauth_config.oauth_client_id = import.meta.env.VITE_OSM_OAUTH_CLIENT_ID
this._oauth_config.oauth_secret = import.meta.env.VITE_OSM_OAUTH_SECRET
2023-09-27 22:21:35 +02:00
}
this.userDetails = new UIEventSource<UserDetails>(
new UserDetails(this._oauth_config.url),
"userDetails"
)
if (options.fakeUser) {
const ud = this.userDetails.data
ud.csCount = 5678
2023-10-30 13:45:44 +01:00
ud.uid = 42
2023-09-27 22:21:35 +02:00
ud.loggedIn = true
ud.unreadMessages = 0
ud.name = "Fake user"
ud.totalMessages = 42
ud.languages = ["en"]
2024-03-11 00:01:44 +01:00
this.loadingStatus.setData("logged-in")
2023-09-27 22:21:35 +02:00
}
this.UpdateCapabilities()
2024-03-11 00:01:44 +01:00
2023-09-27 22:21:35 +02:00
this.isLoggedIn = this.userDetails.map(
(user) =>
user.loggedIn &&
(this.apiIsOnline.data === "unknown" || this.apiIsOnline.data === "online"),
2023-09-27 22:21:35 +02:00
[this.apiIsOnline]
)
this.isLoggedIn.addCallback((isLoggedIn) => {
if (this.userDetails.data.loggedIn == false && isLoggedIn == true) {
2023-09-27 22:21:35 +02:00
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
// This means someone attempted to toggle this; so we attempt to login!
this.AttemptLogin()
2023-09-27 22:21:35 +02:00
}
})
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.updateAuthObject()
2024-04-13 02:40:21 +02:00
if (!this.fakeUser) {
this.CheckForMessagesContinuously()
}
2023-09-27 22:21:35 +02:00
2024-03-11 00:01:44 +01:00
this.preferencesHandler = new OsmPreferences(this.auth, this, this.fakeUser)
2023-09-27 22:21:35 +02:00
if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data)
2023-11-02 04:35:32 +01:00
this.auth.bootstrapToken(options.oauth_token.data, (err, result) => {
console.log("Bootstrap token called back", err, result)
this.AttemptLogin()
2023-11-02 04:35:32 +01:00
})
2023-09-27 22:21:35 +02:00
options.oauth_token.setData(undefined)
}
2024-06-16 16:06:26 +02:00
if (
!Utils.runningFromConsole &&
this.auth.authenticated() &&
options.attemptLogin !== false
) {
this.AttemptLogin()
2023-09-27 22:21:35 +02:00
} else {
console.log("Not authenticated")
}
}
public GetPreference<T extends string = string>(
2023-09-27 22:21:35 +02:00
key: string,
defaultValue: string = undefined,
options?: {
documentation?: string
prefix?: string
}
): UIEventSource<T | undefined> {
return <UIEventSource<T>>this.preferencesHandler.GetPreference(key, defaultValue, options)
}
2023-09-27 22:21:35 +02:00
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
return this.preferencesHandler.GetLongPreference(key, prefix)
2020-06-24 00:35:19 +02:00
}
2023-09-27 22:21:35 +02:00
public OnLoggedIn(action: (userDetails: UserDetails) => void) {
this._onLoggedIn.push(action)
}
2023-09-27 22:21:35 +02:00
public LogOut() {
this.auth.logout()
this.userDetails.data.loggedIn = false
this.userDetails.data.csCount = 0
this.userDetails.data.name = ""
this.userDetails.ping()
console.log("Logged out")
this.loadingStatus.setData("not-attempted")
this.preferencesHandler.preferences.setData(undefined)
}
2023-09-27 22:21:35 +02:00
/**
* The backend host, without path or trailing '/'
*
* new OsmConnection().Backend() // => "https://www.openstreetmap.org"
*/
public Backend(): string {
return this._oauth_config.url
}
2023-09-27 22:21:35 +02:00
public AttemptLogin() {
this.UpdateCapabilities()
2023-10-17 01:36:22 +02:00
if (this.loadingStatus.data !== "logged-in") {
// Stay 'logged-in' if we are already logged in; this simply means we are checking for messages
this.loadingStatus.setData("loading")
}
2023-09-27 22:21:35 +02:00
if (this.fakeUser) {
this.loadingStatus.setData("logged-in")
console.log("AttemptLogin called, but ignored as fakeUser is set")
return
}
2023-09-27 22:21:35 +02:00
console.log("Trying to log in...")
this.updateAuthObject()
2023-09-27 22:21:35 +02:00
LocalStorageSource.Get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href
)
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/details"
2023-09-27 22:21:35 +02:00
},
(err, details: XMLDocument) => {
2023-09-27 22:21:35 +02:00
if (err != null) {
console.log("Could not login due to:", err)
this.loadingStatus.setData("error")
2023-09-27 22:21:35 +02:00
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
this.auth.logout()
this.LogOut()
} else {
console.log("Other error. Status:", err.status)
this.apiIsOnline.setData("unreachable")
2023-09-27 22:21:35 +02:00
}
return
}
if (details == null) {
this.loadingStatus.setData("error")
2023-09-27 22:21:35 +02:00
return
}
// details is an XML DOM of user details
const userInfo = details.getElementsByTagName("user")[0]
2023-09-27 22:21:35 +02:00
const data = this.userDetails.data
2023-09-27 22:21:35 +02:00
data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo)
data.name = userInfo.getAttribute("display_name")
data.account_created = userInfo.getAttribute("account_created")
data.uid = Number(userInfo.getAttribute("id"))
data.languages = Array.from(
userInfo.getElementsByTagName("languages")[0].getElementsByTagName("lang")
).map((l) => l.textContent)
2023-09-27 22:21:35 +02:00
data.csCount = Number.parseInt(
userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? "0"
2023-09-27 22:21:35 +02:00
)
data.tracesCount = Number.parseInt(
userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? "0"
2023-09-27 22:21:35 +02:00
)
data.img = undefined
const imgEl = userInfo.getElementsByTagName("img")
if (imgEl !== undefined && imgEl[0] !== undefined) {
data.img = imgEl[0].getAttribute("href")
}
const description = userInfo.getElementsByTagName("description")
if (description !== undefined && description[0] !== undefined) {
data.description = description[0]?.innerHTML
}
const homeEl = userInfo.getElementsByTagName("home")
if (homeEl !== undefined && homeEl[0] !== undefined) {
const lat = parseFloat(homeEl[0].getAttribute("lat"))
const lon = parseFloat(homeEl[0].getAttribute("lon"))
data.home = { lat: lat, lon: lon }
}
this.loadingStatus.setData("logged-in")
2023-09-27 22:21:35 +02:00
const messages = userInfo
.getElementsByTagName("messages")[0]
.getElementsByTagName("received")[0]
data.unreadMessages = parseInt(messages.getAttribute("unread"))
data.totalMessages = parseInt(messages.getAttribute("count"))
this.userDetails.ping()
for (const action of this._onLoggedIn) {
action(this.userDetails.data)
2023-09-27 22:21:35 +02:00
}
this._onLoggedIn = []
2023-09-27 22:21:35 +02:00
}
)
}
/**
* Interact with the API.
*
* @param path the path to query, without host and without '/api/0.6'. Example 'notes/1234/close'
* @param method
* @param header
* @param content
* @param allowAnonymous if set, will use the anonymous-connection if the main connection is not authenticated
2023-09-27 22:21:35 +02:00
*/
public async interact(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE",
header?: Record<string, string>,
content?: string,
allowAnonymous: boolean = false
): Promise<string> {
const connection: osmAuth = this.auth
2023-11-02 04:35:32 +01:00
if (allowAnonymous && !this.auth.authenticated()) {
const possibleResult = await Utils.downloadAdvanced(
`${this.Backend()}/api/0.6/${path}`,
header,
method,
content
)
if (possibleResult["content"]) {
return possibleResult["content"]
}
console.error(possibleResult)
2023-11-02 04:35:32 +01:00
throw "Could not interact with OSM:" + possibleResult["error"]
}
2023-09-27 22:21:35 +02:00
return new Promise((ok, error) => {
connection.xhr(
{
2023-09-27 22:21:35 +02:00
method,
headers: header,
2023-09-27 22:21:35 +02:00
content,
path: `/api/0.6/${path}`
2023-09-27 22:21:35 +02:00
},
function(err, response) {
2023-09-27 22:21:35 +02:00
if (err !== null) {
error(err)
} else {
ok(response)
}
}
)
})
}
public async post<T = string>(
2023-09-27 22:21:35 +02:00
path: string,
content?: string,
header?: Record<string, string>,
allowAnonymous: boolean = false
): Promise<T> {
2024-06-16 16:06:26 +02:00
return <T>await this.interact(path, "POST", header, content, allowAnonymous)
2023-09-27 22:21:35 +02:00
}
public async put<T extends string>(
2023-09-27 22:21:35 +02:00
path: string,
content?: string,
header?: Record<string, string>
): Promise<T> {
2024-06-16 16:06:26 +02:00
return <T>await this.interact(path, "PUT", header, content)
2023-09-27 22:21:35 +02:00
}
2023-11-02 04:35:32 +01:00
public async get(
path: string,
header?: Record<string, string>,
2023-11-02 04:35:32 +01:00
allowAnonymous: boolean = false
): Promise<string> {
return await this.interact(path, "GET", header, undefined, allowAnonymous)
2023-09-27 22:21:35 +02:00
}
public closeNote(id: number | string, text?: string): Promise<string> {
2023-09-27 22:21:35 +02:00
let textSuffix = ""
if ((text ?? "") !== "") {
textSuffix = "?text=" + encodeURIComponent(text)
}
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text)
return new Promise((ok) => {
ok("")
2023-09-27 22:21:35 +02:00
})
2022-01-08 04:22:50 +01:00
}
2023-09-27 22:21:35 +02:00
return this.post(`notes/${id}/close${textSuffix}`)
}
2022-01-08 04:22:50 +01:00
public reopenNote(id: number | string, text?: string): Promise<string> {
2023-09-27 22:21:35 +02:00
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text)
2024-06-16 16:06:26 +02:00
return new Promise((resolve) => {
resolve("")
2023-09-27 22:21:35 +02:00
})
}
2023-09-27 22:21:35 +02:00
let textSuffix = ""
if ((text ?? "") !== "") {
textSuffix = "?text=" + encodeURIComponent(text)
}
return this.post(`notes/${id}/reopen${textSuffix}`)
}
public async openNote(lat: number, lon: number, text: string): Promise<{ id: number }> {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually opening note with text ", text)
return new Promise<{ id: number }>((ok) => {
window.setTimeout(
() => ok({ id: Math.floor(Math.random() * 1000) }),
Math.random() * 5000
)
})
}
// Lat and lon must be strings for the API to accept it
const content = `lat=${lat}&lon=${lon}&text=${encodeURIComponent(text)}`
2023-11-02 04:35:32 +01:00
const response = await this.post(
"notes.json",
content,
{
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
2023-11-02 04:35:32 +01:00
},
true
)
2023-09-27 22:21:35 +02:00
const parsed = JSON.parse(response)
console.log("Got result:", parsed)
2023-09-27 22:21:35 +02:00
const id = parsed.properties
console.log("OPENED NOTE", id)
return id
}
2022-09-08 21:40:48 +02:00
public static GpxTrackVisibility = ["private", "public", "trackable", "identifiable"] as const
2023-09-27 22:21:35 +02:00
public async uploadGpxTrack(
gpx: string,
options: {
description: string
visibility: (typeof OsmConnection.GpxTrackVisibility)[number]
2023-09-27 22:21:35 +02:00
filename?: string
/**
* Some words to give some properties;
*
* Note: these are called 'tags' on the wiki, but I opted to name them 'labels' instead as they aren't "key=value" tags, but just words.
*/
labels: string[]
2022-11-02 14:44:06 +01:00
}
2023-09-27 22:21:35 +02:00
): Promise<{ id: number }> {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually uploading GPX ", gpx)
2024-01-24 23:45:20 +01:00
return new Promise<{ id: number }>((ok) => {
2023-09-27 22:21:35 +02:00
window.setTimeout(
() => ok({ id: Math.floor(Math.random() * 1000) }),
Math.random() * 5000
)
})
}
2023-09-27 22:21:35 +02:00
const contents = {
file: gpx,
description: options.description,
2023-09-27 22:21:35 +02:00
tags: options.labels?.join(",") ?? "",
visibility: options.visibility
2023-09-27 22:21:35 +02:00
}
if (!contents.description) {
throw "The description of a GPS-trace cannot be the empty string, undefined or null"
}
2023-09-27 22:21:35 +02:00
const extras = {
file:
"; filename=\"" +
2023-09-27 22:21:35 +02:00
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
"\"\r\nContent-Type: application/gpx+xml"
}
2023-09-27 22:21:35 +02:00
const boundary = "987654"
let body = ""
for (const key in contents) {
body += "--" + boundary + "\r\n"
body += "Content-Disposition: form-data; name=\"" + key + "\""
2023-09-27 22:21:35 +02:00
if (extras[key] !== undefined) {
body += extras[key]
}
body += "\r\n\r\n"
body += contents[key] + "\r\n"
}
2023-09-27 22:21:35 +02:00
body += "--" + boundary + "--\r\n"
const response = await this.post("gpx/create", body, {
"Content-Type": "multipart/form-data; boundary=" + boundary,
"Content-Length": "" + body.length
2023-09-27 22:21:35 +02:00
})
const parsed = JSON.parse(response)
console.log("Uploaded GPX track", parsed)
return { id: parsed }
}
2023-09-27 22:21:35 +02:00
public addCommentToNote(id: number | string, text: string): Promise<void> {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id)
2024-07-09 10:54:41 +02:00
return Utils.waitFor(1000)
2023-09-27 22:21:35 +02:00
}
if ((text ?? "") === "") {
throw "Invalid text!"
}
return new Promise((ok, error) => {
this.auth.xhr(
{
method: "POST",
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`
2023-09-27 22:21:35 +02:00
},
function(err) {
2023-09-27 22:21:35 +02:00
if (err !== null) {
error(err)
} else {
ok()
}
}
)
})
}
2023-09-27 22:21:35 +02:00
/**
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function() {
2023-09-27 22:21:35 +02:00
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
callback(previousLocation.data)
})
}
2023-09-27 22:21:35 +02:00
private updateAuthObject() {
this.auth = new osmAuth({
client_id: this._oauth_config.oauth_client_id,
url: this._oauth_config.url,
scope: "read_prefs write_prefs write_api write_gpx write_notes openid",
2023-09-27 22:21:35 +02:00
redirect_uri: Utils.runningFromConsole
? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html",
singlepage: true, // We always use 'singlePage', it is the most stable - including in PWA
2024-06-16 16:06:26 +02:00
auto: true,
apiUrl: this._oauth_config.api_url ?? this._oauth_config.url
2023-09-27 22:21:35 +02:00
})
}
2023-09-27 22:21:35 +02:00
private CheckForMessagesContinuously() {
if (this.isChecking) {
return
}
Stores.Chronic(3 * 1000).addCallback(() => {
if (!(this.apiIsOnline.data === "unreachable" || this.apiIsOnline.data === "offline")) {
return
}
try {
console.log("Api is offline - trying to reconnect...")
this.AttemptLogin()
} catch (e) {
console.log("Could not login due to", e)
}
})
this.isChecking = true
if (!this._doCheckRegularly) {
return
}
Stores.Chronic(60 * 5 * 1000).addCallback(() => {
if (this.isLoggedIn.data) {
try {
this.AttemptLogin()
} catch (e) {
console.log("Could not login due to", e)
}
2023-09-27 22:21:35 +02:00
}
})
}
2023-09-27 22:21:35 +02:00
private UpdateCapabilities(): void {
if (this.fakeUser) {
return
}
2023-09-27 22:21:35 +02:00
this.FetchCapabilities().then(({ api, gpx }) => {
2024-03-11 00:01:44 +01:00
this.apiIsOnline.setData(api)
this.gpxServiceIsOnline.setData(gpx)
2023-09-27 22:21:35 +02:00
})
}
2023-09-27 22:21:35 +02:00
private readonly _userInfoCache: Record<number, OsmUserInfo> = {}
public async getInformationAboutUser(id: number): Promise<OsmUserInfo> {
2023-11-02 04:35:32 +01:00
if (id === undefined) {
return undefined
}
if (this._userInfoCache[id]) {
return this._userInfoCache[id]
}
const info = await this.get("user/" + id + ".json", { accepts: "application/json" }, true)
const parsed = JSON.parse(info)["user"]
this._userInfoCache[id] = parsed
return parsed
}
2023-09-27 22:21:35 +02:00
private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> {
if (Utils.runningFromConsole) {
return { api: "online", gpx: "online" }
}
const result = await Utils.downloadAdvanced(this.Backend() + "/api/0.6/capabilities")
if (result["content"] === undefined) {
console.log("Something went wrong:", result)
return { api: "unreachable", gpx: "unreachable" }
}
const xmlRaw = result["content"]
const parsed = new DOMParser().parseFromString(xmlRaw, "text/xml")
const statusEl = parsed.getElementsByTagName("status")[0]
const api = <OsmServiceState>statusEl.getAttribute("api")
const gpx = <OsmServiceState>statusEl.getAttribute("gpx")
return { api, gpx }
}
2020-06-24 00:35:19 +02:00
}