initial commit

This commit is contained in:
Jan-Henrik 2024-01-13 15:20:18 +01:00
commit 497f7027d2
8 changed files with 2627 additions and 0 deletions

122
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Create and publish a Docker image
on:
push:
branches: ['main']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm/v7
- linux/arm64
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-multi-${{ matrix.platform }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-multi-${{ matrix.platform }}-buildx
-
name: Build and push by digest
id: build
uses: docker/build-push-action@v4
with:
context: .
platforms: ${{ matrix.platform }}
#tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=local,src=/tmp/.buildx-cache
# Note the mode=max here
# More: https://github.com/moby/buildkit#--export-cache-options
# And: https://github.com/docker/buildx#--cache-tonametypetypekeyvalue
cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
-
name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
uses: actions/upload-artifact@v3
with:
name: digests
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
-
name: Download digests
uses: actions/download-artifact@v3
with:
name: digests
path: /tmp/digests
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2065
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "notes2ics"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1", features = ["full"] }
warp = "0.3"
reqwest = { version = "0.11", features = ["native-tls-vendored", "cookies"] }
anyhow = "1"
icalendar = { version = "0.15", features = ["chrono-tz"] }
chrono = "0"
dyn-fmt = "0.4.0"
quick-xml = { version = "0.31", features = ["serialize"] }
serde = { version = "1.0", features = ["derive"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing = "0.1"
envconfig = "0.10"

37
Dockerfile Normal file
View file

@ -0,0 +1,37 @@
FROM --platform=$BUILDPLATFORM rust:1.70 as cross
ARG TARGETARCH
COPY docker/platform.sh .
RUN ./platform.sh # should write /.platform and /.compiler
RUN rustup target add $(cat /.platform)
RUN apt update && apt-get install -y unzip $(cat /.compiler)
WORKDIR ./ics-adapter
ADD . ./
RUN cargo build --release --target $(cat /.platform)
RUN cp ./target/$(cat /.platform)/release/ics-adapter /ics-adapter.bin # Get rid of this when build --out is stable
FROM debian:buster-slim
ARG APP=/usr/src/app
RUN apt-get update \
&& apt-get install -y ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
EXPOSE 3000
ENV TZ=Etc/UTC \
APP_USER=appuser
RUN groupadd $APP_USER \
&& useradd -g $APP_USER $APP_USER \
&& mkdir -p ${APP}
COPY --from=cross /ics-adapter.bin ${APP}/ics-adapter
RUN chown -R $APP_USER:$APP_USER ${APP}
USER $APP_USER
WORKDIR ${APP}
CMD ["./ics-adapter"]

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
services:
notes2ics:
image: ghcr.io/jbruhn/notes2ics:main
environment:
NOTES_USERNAME: <username>
NOTES_PASSWORD: <password>
NOTES_HOST: domino2.something.de
HOST: 0.0.0.0
PORT: 3000
ports:
- 3000:3000
# http://localhost:3000/calendar/<username>?startDays=-7&endDays=31&filterInvites=true

19
docker/platform.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# Used in Docker build to set platform dependent variables
case $TARGETARCH in
"amd64")
echo "x86_64-unknown-linux-gnu" > /.platform
echo "" > /.compiler
;;
"arm64")
echo "aarch64-unknown-linux-gnu" > /.platform
echo "gcc-aarch64-linux-gnu" > /.compiler
;;
"arm")
echo "armv7-unknown-linux-gnueabihf" > /.platform
echo "gcc-arm-linux-gnueabihf" > /.compiler
;;
esac

351
src/main.rs Normal file
View file

@ -0,0 +1,351 @@
use dyn_fmt::AsStrFormatExt;
use reqwest::ClientBuilder;
use serde::Deserialize;
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Arc;
use tracing_subscriber::fmt::format::FmtSpan;
use warp::{Filter, Rejection, Reply};
use envconfig::Envconfig;
const DATETIME_FORMAT: &str = "%Y%m%dT%H%M%S,00%#z";
const LOGIN_URL: &str = "names.nsf?Login";
const CALENDAR_URL: &str = "mail/{}.nsf/($Calendar)?ReadViewEntries&Count=-1&KeyType=time";
// StartKey
// UntilKey
#[derive(Envconfig, Clone)]
struct Config {
#[envconfig(from = "NOTES_USERNAME")]
pub notes_username: String,
#[envconfig(from = "NOTES_PASSWORD")]
pub notes_password: String,
#[envconfig(from = "NOTES_HOST")]
pub notes_host: String,
#[envconfig(from = "PORT", default = "3000")]
pub port: u16,
#[envconfig(from = "HOST", default = "0.0.0.0")]
pub host: String,
}
async fn login(client: &reqwest::Client, host: &str, username: &str, password: &str) -> anyhow::Result<()> {
let mut params = HashMap::new();
params.insert("Username", username);
params.insert("Password", password);
let target = format!("/{}", CALENDAR_URL.format(&[username]));
//let target = format!("https://{}{}", host, &target.as_str());
params.insert("RedirectTo", &target.as_str());
let response = client
.post(format!("https://{}/{}", host, LOGIN_URL))
.form(&params)
.send()
.await;
let logged_in = response?.status() == 302; // redirecting to target ressource
if logged_in {
Ok(())
} else {
anyhow::bail!("Invalid credentials!")
}
}
async fn load_calendar(
client: &reqwest::Client,
host: &str,
username: &str,
days_back: i64,
days_ahead: i64,
) -> anyhow::Result<String> {
let start =
(chrono::Utc::now() + chrono::Duration::days(days_back)).format("%Y%m%dT000001,00Z");
let end = (chrono::Utc::now() + chrono::Duration::days(days_ahead)).format("%Y%m%dT000001,00Z");
let target = CALENDAR_URL.format(&[username]);
let target = format!(
"https://{}/{}&StartKey={}&UntilKey={}",
host,
&target.as_str(),
start,
end
);
let response = client.get(target).send().await?;
let text = response.text().await?;
let logged_in = text.contains("viewentries");
if logged_in {
Ok(text)
} else {
anyhow::bail!("Not logged in perhaps?")
}
}
#[derive(Debug)]
struct CustomReject(anyhow::Error);
impl warp::reject::Reject for CustomReject {}
pub(crate) fn custom_reject(error: impl Into<anyhow::Error>) -> warp::Rejection {
warp::reject::custom(CustomReject(error.into()))
}
async fn handler(
calendar_user: String,
client: Arc<reqwest::Client>,
config: Config,
params: HashMap<String, String>,
) -> anyhow::Result<impl Reply, Rejection> {
let days_back = params
.get("startDays")
.and_then(|x| x.parse().ok())
.unwrap_or(-14);
let days_ahead = params
.get("endDays")
.and_then(|x| x.parse().ok())
.unwrap_or(31);
let filter_invites = params
.get("filterInvites")
.and_then(|x| x.parse().ok())
.unwrap_or(true);
let calendar_str = load_calendar(&client, &config.notes_host, &calendar_user, days_back, days_ahead).await;
if let Ok(calendar_str) = calendar_str {
tracing::info!("Got calendar for {}", calendar_user);
Ok(parse_calendar(&calendar_str, filter_invites)
.map_err(custom_reject)?
.to_string())
} else {
tracing::info!("Logging in again");
login(&client, &config.notes_host, &config.notes_username, &config.notes_password)
.await
.map_err(custom_reject)?;
tracing::info!("Logged in, loading calendar");
Ok(parse_calendar(
&load_calendar(&client, &config.notes_host, &calendar_user, days_back, days_ahead)
.await
.map_err(custom_reject)?,
filter_invites,
)
.map_err(custom_reject)?
.to_string())
}
}
fn with_client(
client: Arc<reqwest::Client>,
) -> impl Filter<Extract = (Arc<reqwest::Client>,), Error = Infallible> + Clone {
warp::any().map(move || client.clone())
}
fn with_config(
config: Config
) -> impl Filter<Extract = (Config,), Error = Infallible> + Clone {
warp::any().map(move || config.clone())
}
#[tokio::main]
async fn main() {
let filter = std::env::var("RUST_LOG")
.unwrap_or_else(|_| "notes2ics=debug,tracing=info,warp=debug".to_owned());
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_span_events(FmtSpan::CLOSE)
.init();
let config = Config::init_from_env().unwrap();
let client = ClientBuilder::new()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let client = Arc::new(client);
let get_calendar_route = warp::path!("calendar" / String)
.and(with_client(client))
.and(with_config(config.clone()))
.and(warp::query::<HashMap<String, String>>())
.and_then(handler);
warp::serve(
warp::get()
.and(get_calendar_route)
.with(warp::trace::request()),
)
.run((config.host.parse::<std::net::Ipv4Addr>().unwrap(), config.port))
.await
}
#[derive(Deserialize, Debug)]
struct CalendarDocument {
#[serde(rename = "@timestamp")]
_timestamp: String,
#[serde(rename = "$value", default)]
viewentries: Vec<ViewEntry>,
}
#[derive(Deserialize, Debug)]
struct ViewEntry {
#[serde(rename = "$value", default)]
entry_datas: Vec<EntryData>,
#[serde(rename = "@unid", default)]
uid: String,
}
#[derive(Deserialize, Debug)]
struct EntryData {
#[serde(rename = "@name")]
name: String,
#[serde(rename = "$value")]
data: DataList,
}
#[derive(Deserialize, Debug)]
enum DataList {
#[serde(rename = "datetimelist")]
DateTimeList {
#[serde(rename = "$value")]
datetimes: Vec<DataList>,
},
#[serde(rename = "numberlist")]
NumberList {
#[serde(rename = "$value")]
_number: Vec<DataList>,
},
#[serde(rename = "textlist")]
TextList {
#[serde(rename = "$value")]
texts: Vec<DataList>,
},
#[serde(rename = "datetime")]
DateTime(String),
#[serde(rename = "number")]
Number(i32),
#[serde(rename = "text")]
Text(String),
#[serde(rename = "$text")]
Other(String),
}
impl DataList {
fn try_first_text(&self, skip: usize) -> anyhow::Result<String> {
Ok(match self {
Self::Text(t) if skip == 0 => t.to_string(),
Self::TextList { texts } => texts
.iter()
.skip(skip)
.next()
.ok_or(anyhow::anyhow!("empty"))?
.try_first_text(0)?,
_ => anyhow::bail!("Wrong format"),
})
}
fn try_first_datetime(&self, skip: usize) -> anyhow::Result<String> {
Ok(match self {
Self::DateTime(v) if skip == 0 => v.to_string(),
Self::DateTimeList { datetimes } => datetimes
.iter()
.skip(skip)
.next()
.ok_or(anyhow::anyhow!("empty"))?
.try_first_datetime(0)?,
_ => anyhow::bail!("Wrong format"),
})
}
}
fn parse_calendar(text: &str, filter_invites: bool) -> anyhow::Result<icalendar::Calendar> {
let doc: CalendarDocument = quick_xml::de::from_str(text)?;
let mut calendar = icalendar::Calendar::default();
for view_entry in doc.viewentries {
let name = view_entry
.entry_datas
.iter()
.filter(|x| x.name == "$147")
.next()
.ok_or(anyhow::anyhow!("No summary"))
.and_then(|x| x.data.try_first_text(0));
let location = view_entry
.entry_datas
.iter()
.filter(|x| x.name == "$147")
.next()
.ok_or(anyhow::anyhow!("No summary"))
.and_then(|x| x.data.try_first_text(1));
let start = view_entry
.entry_datas
.iter()
.filter(|x| x.name == "$144")
.next()
.ok_or(anyhow::anyhow!("No Start"))
.and_then(|x| x.data.try_first_datetime(0));
let end = view_entry
.entry_datas
.iter()
.filter(|x| x.name == "$146")
.next()
.ok_or(anyhow::anyhow!("No End"))
.and_then(|x| x.data.try_first_datetime(0));
let day = view_entry
.entry_datas
.iter().filter(|x| x.name == "$134")
.next()
.ok_or(anyhow::anyhow!("No Day"))
.and_then(|x| x.data.try_first_datetime(0));
use icalendar::Component;
use icalendar::EventLike;
let entry: anyhow::Result<icalendar::Event> = (|| {
let day = day?;
let mut new_entry = icalendar::Event::new();
new_entry.uid(&format!("{}-{}", &view_entry.uid, &day)); // Repeat events
// get the same uid,
// but we don't know
// about the repeats here
// so every date is unique.
let summary = name?;
if filter_invites && summary.starts_with("Einladung: ") {
anyhow::bail!("Not accepted");
}
new_entry.summary(&summary);
if let Ok(start) = start {
let end = end?;
new_entry.starts::<icalendar::CalendarDateTime>(
chrono::DateTime::parse_from_str(&start, DATETIME_FORMAT)?
.with_timezone(&chrono::Utc)
.into(),
);
new_entry.ends::<icalendar::CalendarDateTime>(
chrono::DateTime::parse_from_str(&end, DATETIME_FORMAT)?
.with_timezone(&chrono::Utc)
.into(),
);
} else {
// Seems to be all day
new_entry.starts::<chrono::NaiveDate>(
chrono::DateTime::parse_from_str(&day, DATETIME_FORMAT)?
.with_timezone(&chrono::Utc)
.date_naive()
.into(),
);
}
if let Ok(location) = location {
new_entry.location(&location);
}
Ok(new_entry.done())
})();
if let Ok(entry) = entry {
calendar.push(entry);
} else {
tracing::warn!("Invalid ViewEntry: {:?}", entry);
}
}
Ok(calendar)
}